mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: CodeLens + completion to scaffold Vega-Lite params; shared snippet core
This commit is contained in:
@@ -294,6 +294,19 @@ logic is pure `src/core/`; the Monaco glue is thin app-layer services.
|
||||
calculate, aggregate, bin, timeUnit, window, joinaggregate, fold, lookup); each builder takes the
|
||||
columns in scope and seeds each `${n:default}` tab stop with a type-appropriate one. A test
|
||||
round-trips every snippet through `snippetToPlain` to assert it's valid JSON.
|
||||
- `spec-params` — the parameter counterpart: `paramSiteAt` resolves both homes a parameter can take
|
||||
(the **root** spec for a variable widget, the **nearest enclosing unit** for a selection —
|
||||
grammar-confirmed against the bundled schema: a nested unit's `params[]` takes selections only),
|
||||
and `paramPlacementAt` classifies a `params[]` slot (root → both families, nested → selections only)
|
||||
for the completion. `PARAMS` is the catalog (slider, dropdown, radio, checkbox; point, interval); a
|
||||
slider's bounds seed from the numeric field's `numericExtent`, a point selection's field from a
|
||||
categorical column. A test validates every seeded entry against the bundled Vega-Lite schema.
|
||||
- `spec-snippet` — the snippet-insertion text math both scaffolds share: `snippetToPlain`
|
||||
(tab stops → default text, so catalogs and edits round-trip through `JSON.parse` in tests),
|
||||
`arrayAffixes`/`appendEntryEdit` (the comma affixing that keeps a spliced array valid), and
|
||||
`createArrayPropertyEdit` (the inline `"key": [ … ]` first-property insertion and its indent
|
||||
math). Pure `(text, site) → { offset, snippet }` — a bug here writes invalid JSON into the
|
||||
user's editor, so the edits are table-tested applied-and-reparsed, off the Monaco glue.
|
||||
- `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring
|
||||
`isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope
|
||||
at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent).
|
||||
@@ -344,7 +357,8 @@ logic is pure `src/core/`; the Monaco glue is thin app-layer services.
|
||||
|
||||
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
|
||||
wrap/simplify/add-view operations and their surfaces), `spec-transform-scaffold` (the
|
||||
data-transform scaffold — a CodeLens plus a completion), `spec-dataset-hints` (data-column completion, hover,
|
||||
data-transform scaffold — a CodeLens plus a completion), `spec-param-scaffold` (the parameter
|
||||
scaffold — likewise a CodeLens plus a completion), `spec-dataset-hints` (data-column completion, hover,
|
||||
inlay providers), `spec-expression-hints` (expression completion, signature help, hover, and
|
||||
the diagnostic markers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
|
||||
plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring.
|
||||
@@ -356,6 +370,14 @@ Decision rules:
|
||||
schema and formatter; per-editor registration would duplicate them on remount. Pieces that
|
||||
need the editor handle — the `addAction` context/F1 commands, and the CodeLens whose command
|
||||
runs `executeEdits` — are installed **per editor** and disposed with it.
|
||||
- **Cursor-aware CodeLenses share one skeleton.** The composition lens
|
||||
(`spec-transform-actions`), the transform scaffold, and the parameter scaffold are the same
|
||||
per-editor shape: a draft-gated provider that re-resolves the site under the cursor and refreshes
|
||||
only when a keyed summary of it changes. That skeleton is `installCursorLens(editor, { resolve,
|
||||
keyOf, lensesOf })` (`services/editor-cursor-lens`) — a new cursor lens supplies those three and its
|
||||
own commands, nothing else. The tab-through snippet splice and the completion-slot plumbing both
|
||||
scaffolds perform are shared the same way via `editor-snippet` — the Monaco glue over
|
||||
`core/spec-snippet`'s tested edit math.
|
||||
- **Transform scope.** A transform targets, in order: an explicit selection → the view the
|
||||
cursor sits in (`findViewRange`) → the whole document. A "view" is a composition-array
|
||||
element or a facet/repeat `spec` child; a flat unit spec has no inner view, so it scopes to
|
||||
@@ -434,6 +456,17 @@ Decision rules:
|
||||
the same catalog. A step's `scope` (`shared` when the array is on a composition parent, so it
|
||||
feeds every child) is surfaced so the placement is not a surprise, and comma affixing keeps the
|
||||
array valid whether the slot is empty, between elements, or appended after one without a trailing comma.
|
||||
- **Parameter scaffolding splits by family, because `params` has two homes.** Unlike a `transform`
|
||||
(on every view node), a **variable** widget (slider/dropdown/radio/checkbox, bound via `bind`) is only
|
||||
legal in the **root** `params[]` — a document-global input any view's `filter` can read — while a
|
||||
**selection** (point/interval, via `select`) attaches to the **unit** whose marks it reads. So the
|
||||
scaffold does not reuse `transformSiteAt`: `paramSiteAt` resolves both homes, and the CodeLens is
|
||||
cursor-scoped to match — variable widgets show at the top level (or a single-view spec, where the
|
||||
root _is_ the unit, so both families land on one line), selections on the unit the cursor is in, and a
|
||||
nested unit hides the variable widgets it cannot hold. The completion offers both families in the root
|
||||
array, selections only in a nested unit's. Defaults are data-seeded where the data allows (a slider's
|
||||
`min`/`max` from the numeric field's extent, a point's `fields` from a categorical column) and
|
||||
structured placeholders otherwise (a dropdown's options).
|
||||
- **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's
|
||||
`CodeAction` carries no icon field. Custom iconography lives only where it is supported:
|
||||
CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations.
|
||||
|
||||
@@ -13,6 +13,13 @@ The editor presents the active snippet's spec as formatted JSON with full code-e
|
||||
- The editor's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from _Settings_; this section does not redefine their defaults.
|
||||
- The editor always edits a single active snippet. Selecting a different snippet in the _Snippet Library_, or toggling the Draft/Published view, replaces the editor content with the corresponding spec.
|
||||
|
||||
### Scaffolding assistance
|
||||
|
||||
Beyond the schema's own suggestions, the editor scaffolds common Vega-Lite structures as ready-to-fill skeletons, seeded from the data actually in scope at the cursor. An inserted skeleton arrives with editable placeholders (Tab moves between them), pre-filled with type-appropriate values where the data allows and descriptive names otherwise. Scaffolding acts on the draft only — the published view is a read-only reference and offers none of it.
|
||||
|
||||
- **Data transforms.** With the cursor in a view, inline actions above the code offer the pipeline: _Add transform_ when the view has none, the common steps (filter, aggregate, calculate, bin, timeUnit) on an existing `transform` array. Inside a `transform[]` element slot, typing offers the full step catalog as completions, each seeded with a matching column (a numeric field for aggregate, a temporal one for timeUnit). A step added on a composition parent notes that it applies to every child view below it.
|
||||
- **Parameters.** The same affordance for `params`, split by where Vega-Lite allows each family: **variable** widgets (slider, dropdown — an input control bound to a name the spec can reference) are offered at the spec's top level, the only place they are legal; **selection** parameters (point, interval — interaction on the chart's marks) on the unit view the cursor is in; in a single-view spec, where the top level is the unit, both appear together. Inside a `params[]` element slot, completions offer the catalog — both families in the top-level array, selections only in a nested view's. Defaults are data-seeded where possible: a slider's min/max/step from the numeric field's actual range, a point selection's field from a categorical column.
|
||||
|
||||
## B. Auto-Save of the Draft
|
||||
|
||||
Edits persist automatically so the user never loses work and never needs an explicit "save" action for ordinary editing.
|
||||
|
||||
@@ -54,6 +54,10 @@ import {
|
||||
configureSpecTransformScaffold,
|
||||
installSpecTransformScaffoldCodeLens,
|
||||
} from '../services/spec-transform-scaffold';
|
||||
import {
|
||||
configureSpecParamScaffold,
|
||||
installSpecParamScaffoldCodeLens,
|
||||
} from '../services/spec-param-scaffold';
|
||||
import { runExtract } from '../services/extract-action';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
@@ -179,6 +183,8 @@ configureSpecDatasetHints();
|
||||
configureSpecExpressionHints();
|
||||
// Register the data-transform step-scaffold completion once (docs/architecture/08).
|
||||
configureSpecTransformScaffold();
|
||||
// Register the parameter-scaffold completion once (docs/architecture/08).
|
||||
configureSpecParamScaffold();
|
||||
|
||||
/** The two spec↔config operations, surfaced as an overflow menu (council:
|
||||
* Carbon menu-buttons — overflow for additional options under space
|
||||
@@ -466,6 +472,10 @@ export function SpecEditor() {
|
||||
// — per editor for the same reason: its commands drive this editor's snippet edit.
|
||||
const scaffoldLensSub = installSpecTransformScaffoldCodeLens(editor);
|
||||
|
||||
// Cursor-aware parameter scaffold CodeLens (+ slider / + point …) — per editor,
|
||||
// its command splices the seeded param into this editor via the snippet engine.
|
||||
const paramScaffoldLensSub = installSpecParamScaffoldCodeLens(editor);
|
||||
|
||||
// Validate Vega expressions in the draft and squiggle the invalid ones — per
|
||||
// editor, because it writes markers to this model (docs/architecture/08).
|
||||
// Debounced internally; recomputes on edit and on a draft↔published toggle.
|
||||
@@ -483,6 +493,7 @@ export function SpecEditor() {
|
||||
transformActionsSub.dispose();
|
||||
codeLensSub.dispose();
|
||||
scaffoldLensSub.dispose();
|
||||
paramScaffoldLensSub.dispose();
|
||||
exprMarkersSub.dispose();
|
||||
editor.dispose();
|
||||
editorRef.current = null;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { pathAtOffset } from '@core/spec-cursor';
|
||||
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
|
||||
import { derivedFieldNamesAtPath } from '@core/spec-fields';
|
||||
import { rowsForDataBinding } from '@core/spec-inline-data';
|
||||
import type { ParamField } from '@core/spec-params';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
|
||||
@@ -183,6 +184,21 @@ export function boundColumnsAt(
|
||||
return sourceAt(text, offset).columnTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source columns with their numeric extent in scope at `offset` (for parameter
|
||||
* scaffolding — a slider's bounds come from the field's `numericExtent`). Zips the
|
||||
* profile's type and stat arrays by name; a non-numeric column carries a null extent.
|
||||
*/
|
||||
export function boundParamFieldsAt(text: string, offset: number): ReadonlyArray<ParamField> {
|
||||
const source = sourceAt(text, offset);
|
||||
const extentByName = new Map(source.columnStats.map((s) => [s.name, s.numericExtent]));
|
||||
return source.columnTypes.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
extent: extentByName.get(c.name) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
|
||||
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
|
||||
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The cursor-aware CodeLens skeleton shared by the editor's structural surfaces
|
||||
* (docs/architecture/08 → editor augmentation). A "cursor lens" follows the view the
|
||||
* cursor sits in — recomputing only when the *enclosing* thing changes, not on every
|
||||
* keystroke — and is installed **per editor** because its lens commands need the
|
||||
* editor handle to apply the edit. The composition CodeLens (`spec-transform-actions`),
|
||||
* the data-transform scaffold (`spec-transform-scaffold`), and the parameter scaffold
|
||||
* (`spec-param-scaffold`) are all this one shape; only three things vary between them:
|
||||
* - `resolve(text, offset)` — the site the cursor is in (or null when in none);
|
||||
* - `keyOf(site)` — a string that changes exactly when the lenses should refresh
|
||||
* (so typing *within* the resolved site doesn't churn them);
|
||||
* - `lensesOf(site, model, lens)` — the lenses for that site, built with the shared
|
||||
* `lens` factory and closing over the caller's editor commands.
|
||||
*
|
||||
* The draft gate (scaffolding only edits the active draft; the published view is a
|
||||
* read-only reference) and the position guard live here too, since every consumer
|
||||
* shares them. App-layer glue — imports `monaco` and reads `useSnippetStore`.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** Builds a zero-width CodeLens at the start of `line` invoking command `id` with `args`. */
|
||||
export type LensFactory = (
|
||||
line: number,
|
||||
title: string,
|
||||
id: string | null,
|
||||
args: unknown[],
|
||||
) => monaco.languages.CodeLens;
|
||||
|
||||
/** What varies between one cursor-lens consumer and another. */
|
||||
export interface CursorLensSpec<Site> {
|
||||
/** The site the cursor is in, or null when it is in none. */
|
||||
resolve(text: string, offset: number): Site | null;
|
||||
/** A key that changes exactly when the lenses should refresh. */
|
||||
keyOf(site: Site): string;
|
||||
/** The lenses for `site`, built with `lens` (which closes over the caller's commands). */
|
||||
lensesOf(
|
||||
site: Site,
|
||||
model: monaco.editor.ITextModel,
|
||||
lens: LensFactory,
|
||||
): monaco.languages.CodeLens[];
|
||||
}
|
||||
|
||||
const NO_LENSES: monaco.languages.CodeLensList = { lenses: [], dispose() {} };
|
||||
|
||||
/** True when the active draft is being edited (the only place scaffolding acts). */
|
||||
function onDraft(): boolean {
|
||||
return useSnippetStore.getState().editorView === 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a cursor-aware CodeLens provider on `editor`, disposed with it. The provider
|
||||
* refreshes when `keyOf(resolve(...))` changes under the cursor; between refreshes
|
||||
* Monaco reuses the last lenses. Returns a disposable that tears down the cursor
|
||||
* subscription, the refresh emitter, and the provider registration together.
|
||||
*/
|
||||
export function installCursorLens<Site>(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
spec: CursorLensSpec<Site>,
|
||||
): monaco.IDisposable {
|
||||
const lens: LensFactory = (line, title, id, args) => ({
|
||||
range: new monaco.Range(line, 1, line, 1),
|
||||
command: { id: id ?? '', title, arguments: args },
|
||||
});
|
||||
|
||||
/** Resolve the site under the cursor, gated to the draft. */
|
||||
const siteUnderCursor = (model: monaco.editor.ITextModel | null): Site | null => {
|
||||
const pos = editor.getPosition();
|
||||
if (!model || !pos || !onDraft()) return null;
|
||||
return spec.resolve(model.getValue(), model.getOffsetAt(pos));
|
||||
};
|
||||
|
||||
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
|
||||
const provider: monaco.languages.CodeLensProvider = {
|
||||
onDidChange: onDidChange.event,
|
||||
provideCodeLenses(model) {
|
||||
const site = siteUnderCursor(model);
|
||||
if (!site) return NO_LENSES;
|
||||
return { lenses: spec.lensesOf(site, model, lens), dispose() {} };
|
||||
},
|
||||
};
|
||||
const registration = monaco.languages.registerCodeLensProvider('json', provider);
|
||||
|
||||
let lastKey = '';
|
||||
const cursorSub = editor.onDidChangeCursorPosition(() => {
|
||||
const site = siteUnderCursor(editor.getModel());
|
||||
const key = site ? spec.keyOf(site) : '';
|
||||
if (key !== lastKey) {
|
||||
lastKey = key;
|
||||
onDidChange.fire(provider);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
cursorSub.dispose();
|
||||
onDidChange.dispose();
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Monaco snippet glue shared by the scaffold services (`spec-transform-scaffold`,
|
||||
* `spec-param-scaffold`; docs/architecture/08 → editor augmentation): inserting a
|
||||
* `${n:default}` template as a live, Tab-through session, and the completion-side
|
||||
* plumbing — the slot a scaffold suggestion replaces and the suggestion item itself.
|
||||
* The pure text math (offsets, comma affixing) is `core/spec-snippet`; this file is
|
||||
* only what needs Monaco types or the editor handle.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { arrayAffixes } from '@core/spec-snippet';
|
||||
|
||||
/** Monaco's snippet contribution — the public entry to insert a `${n:…}` template with tab stops. */
|
||||
interface SnippetInserter extends monaco.editor.IEditorContribution {
|
||||
insert(template: string, opts?: { adjustWhitespace?: boolean }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `template` at `offset` through Monaco's snippet engine, so its
|
||||
* `${n:default}` tab stops become a live, Tab-through session. Places the cursor first
|
||||
* (the controller inserts at the selection). `adjustWhitespace: false` keeps the engine
|
||||
* from re-basing our explicit indentation to the insertion line's — it otherwise leaves
|
||||
* a multi-line block's closing bracket under-indented. No-op if the contribution is
|
||||
* absent (it ships in `edcore.main`, so this is just defensive).
|
||||
*/
|
||||
export function insertSnippetAt(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
offset: number,
|
||||
template: string,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
editor.setPosition(model.getPositionAt(offset));
|
||||
editor.focus();
|
||||
editor.getContribution<SnippetInserter>('snippetController2')?.insert(template, {
|
||||
adjustWhitespace: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** The array-element slot a scaffold completion fills. */
|
||||
export interface ScaffoldSlot {
|
||||
/** The range the suggestion replaces (the bare partial the user typed). */
|
||||
range: monaco.Range;
|
||||
/** Comma affixes that keep the surrounding array valid around the inserted entry. */
|
||||
lead: string;
|
||||
trail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion slot at `position` for an array-element scaffold: the bare alphabetic
|
||||
* partial before the cursor, parsed from the line — never `getWordUntilPosition`, whose
|
||||
* JSON wordPattern reaches across punctuation (docs/architecture/08 → completion
|
||||
* ranges) — plus the comma affixes for the element the suggestion becomes.
|
||||
*/
|
||||
export function scaffoldSlotAt(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
text: string,
|
||||
offset: number,
|
||||
): ScaffoldSlot {
|
||||
const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||
const partial = /[A-Za-z]*$/.exec(before)?.[0] ?? '';
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column - partial.length,
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
);
|
||||
const { lead, trail } = arrayAffixes(text, offset - partial.length, offset);
|
||||
return { range, lead, trail };
|
||||
}
|
||||
|
||||
/**
|
||||
* A catalog entry as a snippet completion in `slot` — comma-affixed to keep the array
|
||||
* valid, and sorted by catalog index above the schema's generic items.
|
||||
*/
|
||||
export function scaffoldSuggestion(
|
||||
slot: ScaffoldSlot,
|
||||
index: number,
|
||||
item: { label: string; detail: string; body: string; documentation?: monaco.IMarkdownString },
|
||||
): monaco.languages.CompletionItem {
|
||||
return {
|
||||
label: item.label,
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
detail: item.detail,
|
||||
documentation: item.documentation,
|
||||
insertText: `${slot.lead}${item.body}${slot.trail}`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range: slot.range,
|
||||
sortText: `0${String(index).padStart(2, '0')}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Parameter scaffolding for the editor (docs/architecture/08 → editor augmentation) —
|
||||
* the "quick parameter" affordance, sibling of `spec-transform-scaffold`. It offers
|
||||
* Vega-Lite parameters as ready-to-fill snippets: **variable** widgets (slider,
|
||||
* dropdown, radio, checkbox — bound via `bind`) and **selection** parameters (point,
|
||||
* interval — via `select`), each seeded from the data in scope (a slider's bounds from
|
||||
* the numeric field's extent, a point selection's field from a categorical column).
|
||||
* The facts the schema can't supply — *where each family legally attaches* and *what a
|
||||
* seeded skeleton looks like* — are pure core (`core/spec-params`); this is the Monaco
|
||||
* glue.
|
||||
*
|
||||
* **Two surfaces, mirroring `spec-transform-scaffold`:**
|
||||
* - the **CodeLens** (`installSpecParamScaffoldCodeLens`, per editor) is the
|
||||
* discoverable home. Because the two families attach to different homes — variable
|
||||
* parameters are document-global (only legal in the root `params[]`), selections
|
||||
* belong to the unit whose marks they read — the *kind* is chosen at the lens, and
|
||||
* the lens is cursor-scoped by family: at the top level (or a single-view spec) the
|
||||
* variable widgets show; inside a unit the selections show; a single-view spec, where
|
||||
* the root *is* the unit, shows both on the one line. Clicking creates `params: []`
|
||||
* with the entry, or appends to an existing array.
|
||||
* - the **completion** (`configureSpecParamScaffold`, global-once) is the
|
||||
* type-to-filter accelerator, offering both families in the root array and
|
||||
* selections only in a nested unit's.
|
||||
*
|
||||
* Both are gated to the active draft. This is the home the schema leaves bare: the
|
||||
* schema completes the `params` key and its enum values, but never a seeded slider with
|
||||
* your column's real range or a `select` wired to the view.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
|
||||
import {
|
||||
PARAMS,
|
||||
type ParamFamily,
|
||||
type ParamHost,
|
||||
type ParamSite,
|
||||
paramPlacementAt,
|
||||
paramSiteAt,
|
||||
} from '@core/spec-params';
|
||||
import { boundParamFieldsAt } from './active-dataset';
|
||||
import { installCursorLens } from './editor-cursor-lens';
|
||||
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** The kinds surfaced as CodeLens buttons — the common few per family; the completion has the rest. */
|
||||
const COMMON_VARIABLE_IDS = ['slider', 'dropdown'] as const;
|
||||
const COMMON_SELECTION_IDS = ['point', 'interval'] as const;
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Register the `params[]` scaffold completion provider once. */
|
||||
export function configureSpecParamScaffold(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('json', {
|
||||
provideCompletionItems(model, position) {
|
||||
// Scaffolding only edits the draft; the published view is read-only.
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
|
||||
|
||||
const text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
const placement = paramPlacementAt(text, offset);
|
||||
if (placement.kind !== 'slot') return { suggestions: [] };
|
||||
|
||||
const slot = scaffoldSlotAt(model, position, text, offset);
|
||||
const fields = boundParamFieldsAt(text, offset);
|
||||
// The root array takes both families; a nested unit's takes selections only.
|
||||
const kinds = placement.atRoot ? PARAMS : PARAMS.filter((p) => p.family === 'selection');
|
||||
|
||||
const suggestions = kinds.map((p, i) =>
|
||||
scaffoldSuggestion(slot, i, { label: p.label, detail: p.detail, body: p.param(fields) }),
|
||||
);
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a parameter of `id`/`family` to its home — the root (variable) or the unit
|
||||
* (selection) at `anchorOffset`. Re-resolves the site from live text (the lens args may
|
||||
* be a version stale), then appends to an existing `params[]` or creates one with this
|
||||
* entry as its first element. Both go through Monaco's snippet engine so the entry's tab
|
||||
* stops are Tab-through.
|
||||
*/
|
||||
function runAddParam(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
id: string,
|
||||
family: ParamFamily,
|
||||
anchorOffset: number,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const text = model.getValue();
|
||||
const site = paramSiteAt(text, anchorOffset + 1);
|
||||
const host = family === 'variable' ? site?.root : site?.unit;
|
||||
const kind = PARAMS.find((p) => p.id === id);
|
||||
if (!host || !kind) return;
|
||||
|
||||
const entry = kind.param(boundParamFieldsAt(text, anchorOffset + 1));
|
||||
|
||||
// Append to the existing `params[]`, or create one with this entry as its first
|
||||
// element — placement, indentation, and commas are the core edits' tested math.
|
||||
const edit = host.array
|
||||
? appendEntryEdit(text, host.array, entry)
|
||||
: createArrayPropertyEdit(
|
||||
text,
|
||||
host.view.offset,
|
||||
model.getOptions().tabSize || 2,
|
||||
'params',
|
||||
entry,
|
||||
);
|
||||
insertSnippetAt(editor, edit.offset, edit.snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the cursor-aware parameter-scaffold CodeLens (per editor — its command needs
|
||||
* this editor's handle to apply the edit). Variable widgets show at the root unless the
|
||||
* cursor is inside a nested unit; selections show on the unit the cursor is in. Returns
|
||||
* a disposable; dispose on unmount.
|
||||
*/
|
||||
export function installSpecParamScaffoldCodeLens(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
const addParamCmd = editor.addCommand(
|
||||
0,
|
||||
(_a, id: string, family: ParamFamily, anchorOffset: number) =>
|
||||
runAddParam(editor, id, family, anchorOffset),
|
||||
);
|
||||
|
||||
return installCursorLens<ParamSite>(editor, {
|
||||
resolve: paramSiteAt,
|
||||
// Refresh when either home's presence or param count changes, not on every keystroke.
|
||||
keyOf: (site) =>
|
||||
JSON.stringify([
|
||||
site.root.view.offset,
|
||||
site.root.array?.count ?? -1,
|
||||
site.unit?.view.offset ?? -1,
|
||||
site.unit?.array?.count ?? -1,
|
||||
]),
|
||||
lensesOf: (site, model, lens) => {
|
||||
// Anchor on the params array's line when it exists, else the object's opening line.
|
||||
const lineFor = (host: ParamHost) =>
|
||||
model.getPositionAt(host.array ? host.array.offset : host.view.offset).lineNumber;
|
||||
|
||||
const lenses: monaco.languages.CodeLens[] = [];
|
||||
// Variable widgets are root-only; hide them when the cursor is in a nested unit,
|
||||
// where only selections are legal.
|
||||
const inNestedUnit = !!site.unit && site.unit.view.offset !== site.root.view.offset;
|
||||
if (!inNestedUnit) {
|
||||
const line = lineFor(site.root);
|
||||
for (const id of COMMON_VARIABLE_IDS) {
|
||||
lenses.push(
|
||||
lens(line, `$(add) ${id}`, addParamCmd, [id, 'variable', site.root.view.offset]),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (site.unit) {
|
||||
const line = lineFor(site.unit);
|
||||
for (const id of COMMON_SELECTION_IDS) {
|
||||
lenses.push(
|
||||
lens(line, `$(add) ${id}`, addParamCmd, [id, 'selection', site.unit.view.offset]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return lenses;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { formatJson } from '@core/json-format';
|
||||
import { isJsonObject, type JsonObject } from '@core/spec-config';
|
||||
import { findViewRange } from '@core/spec-cursor';
|
||||
import {
|
||||
type CompositionTarget,
|
||||
compositionTargetAt,
|
||||
elementOffset,
|
||||
insertView,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { boundColumnsAt } from './active-dataset';
|
||||
import { installCursorLens } from './editor-cursor-lens';
|
||||
import { parseSpecObject } from './spec-config-actions';
|
||||
|
||||
/** The composition operators the wrap actions offer. */
|
||||
@@ -475,36 +477,18 @@ export function installSpecTransformCodeLens(
|
||||
runMoveView(editor, path, index, delta),
|
||||
);
|
||||
|
||||
const lens = (
|
||||
line: number,
|
||||
title: string,
|
||||
id: string | null,
|
||||
args: unknown[],
|
||||
): monaco.languages.CodeLens => ({
|
||||
range: new monaco.Range(line, 1, line, 1),
|
||||
command: { id: id ?? '', title, arguments: args },
|
||||
});
|
||||
|
||||
// The lenses depend on the cursor, so signal a refresh when the enclosing view
|
||||
// changes — keyed so typing within one view does not re-render them.
|
||||
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
|
||||
const codeLensProvider: monaco.languages.CodeLensProvider = {
|
||||
onDidChange: onDidChange.event,
|
||||
provideCodeLenses(model) {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
|
||||
const pos = editor.getPosition();
|
||||
if (!pos) return { lenses: [], dispose() {} };
|
||||
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||||
if (!target) return { lenses: [], dispose() {} };
|
||||
const lenses: monaco.languages.CodeLens[] = [];
|
||||
return installCursorLens<CompositionTarget>(editor, {
|
||||
resolve: compositionTargetAt,
|
||||
// Refresh when the enclosing view or the sibling count changes, not on every keystroke.
|
||||
keyOf: (target) =>
|
||||
JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count]),
|
||||
lensesOf: (target, model, lens) => {
|
||||
if (!target.element) {
|
||||
// Off a view, only an empty composition gets an affordance — the array's
|
||||
// own line stays clean otherwise.
|
||||
if (target.count === 0) {
|
||||
if (target.count !== 0) return [];
|
||||
const line = model.getPositionAt(target.arrayOffset).lineNumber;
|
||||
lenses.push(lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0]));
|
||||
}
|
||||
return { lenses, dispose() {} };
|
||||
return [lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0])];
|
||||
}
|
||||
const { index, offset, length } = target.element;
|
||||
// "above" actions sit on the view's first line, "below" on the line after
|
||||
@@ -514,7 +498,9 @@ export function installSpecTransformCodeLens(
|
||||
model.getLineCount(),
|
||||
model.getPositionAt(offset + length).lineNumber + 1,
|
||||
);
|
||||
lenses.push(lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]));
|
||||
const lenses: monaco.languages.CodeLens[] = [
|
||||
lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]),
|
||||
];
|
||||
if (index > 0)
|
||||
lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1]));
|
||||
lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
|
||||
@@ -522,35 +508,9 @@ export function installSpecTransformCodeLens(
|
||||
lenses.push(
|
||||
lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]),
|
||||
);
|
||||
return { lenses, dispose() {} };
|
||||
return lenses;
|
||||
},
|
||||
};
|
||||
const registration = monaco.languages.registerCodeLensProvider('json', codeLensProvider);
|
||||
|
||||
let lastKey = '';
|
||||
const cursorSub = editor.onDidChangeCursorPosition(() => {
|
||||
const model = editor.getModel();
|
||||
const pos = editor.getPosition();
|
||||
const target =
|
||||
model && pos && useSnippetStore.getState().editorView === 'draft'
|
||||
? compositionTargetAt(model.getValue(), model.getOffsetAt(pos))
|
||||
: null;
|
||||
const key = target
|
||||
? JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count])
|
||||
: '';
|
||||
if (key !== lastKey) {
|
||||
lastKey = key;
|
||||
onDidChange.fire(codeLensProvider);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
cursorSub.dispose();
|
||||
onDidChange.dispose();
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,60 +25,20 @@
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import {
|
||||
arrayAffixes,
|
||||
DATA_TRANSFORMS,
|
||||
neighbour,
|
||||
transformPlacementAt,
|
||||
transformSiteAt,
|
||||
type TransformSite,
|
||||
} from '@core/spec-data-transforms';
|
||||
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
|
||||
import { boundColumnsAt } from './active-dataset';
|
||||
import { installCursorLens } from './editor-cursor-lens';
|
||||
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** The transforms surfaced as CodeLens buttons — the common few; the completion has the rest. */
|
||||
const COMMON_STEP_IDS = ['filter', 'aggregate', 'calculate', 'bin', 'timeUnit'] as const;
|
||||
|
||||
// TODO: the cursor-aware CodeLens skeleton (the `lens()` factory + `onDidChange` emitter +
|
||||
// cursor-keyed refresh + dispose) is ALREADY duplicated between this installer and
|
||||
// `spec-transform-actions.installSpecTransformCodeLens` — two shipped sites. A shared
|
||||
// `installCursorLens(editor, resolve, keyOf, lensesOf)` (~-55 LOC) is earned; deferred so it
|
||||
// lands as its own change with both lenses live-verified together, not folded into this
|
||||
// feature commit and its still-unexercised click path.
|
||||
//
|
||||
// TODO: `params` scaffolding is the natural sibling (+ Add parameter → slider / dropdown /
|
||||
// radio / point / interval). It reuses the CodeLens shape but NOT the transform internals:
|
||||
// `params` isn't on every view node the way `transform` is (variable params are top-level;
|
||||
// selection params are unit-only), so it needs its own site resolver, not `transformSiteAt`;
|
||||
// its smart defaults come from column stats (slider min/max ← numericExtent, dropdown options
|
||||
// ← distinct values) and the view's encodings, not the step catalog. Build as a separate
|
||||
// reviewed change — that third consumer is the right moment to shape the shared skeleton above.
|
||||
|
||||
/** Monaco's snippet contribution — the public entry to insert a `${n:…}` template with tab stops. */
|
||||
interface SnippetInserter extends monaco.editor.IEditorContribution {
|
||||
insert(template: string, opts?: { adjustWhitespace?: boolean }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a snippet template at `offset` through Monaco's snippet engine, so its
|
||||
* `${n:default}` tab stops become a live, Tab-through session. Places the cursor
|
||||
* first (the controller inserts at the selection). `adjustWhitespace: false` keeps
|
||||
* the engine from re-basing our explicit indentation to the insertion line's — it
|
||||
* otherwise leaves a multi-line block's closing bracket under-indented. No-op if the
|
||||
* contribution is absent (it ships in `edcore.main`, so this is just defensive).
|
||||
*/
|
||||
function insertSnippetAt(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
offset: number,
|
||||
template: string,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
editor.setPosition(model.getPositionAt(offset));
|
||||
editor.focus();
|
||||
editor.getContribution<SnippetInserter>('snippetController2')?.insert(template, {
|
||||
adjustWhitespace: false,
|
||||
});
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Register the `transform[]` step-scaffold completion provider once. */
|
||||
@@ -96,26 +56,15 @@ export function configureSpecTransformScaffold(): void {
|
||||
const placement = transformPlacementAt(text, offset);
|
||||
if (placement.kind !== 'step') return { suggestions: [] };
|
||||
|
||||
// Replace the bare partial the user is typing as the element (e.g. `fil`),
|
||||
// parsed from the line — never `getWordUntilPosition`, whose JSON wordPattern
|
||||
// would reach across punctuation (docs/architecture/08 → completion ranges).
|
||||
const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||
const partial = /[A-Za-z]*$/.exec(before)?.[0] ?? '';
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column - partial.length,
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
);
|
||||
|
||||
const { lead, trail } = arrayAffixes(text, offset - partial.length, offset);
|
||||
const slot = scaffoldSlotAt(model, position, text, offset);
|
||||
const fields = boundColumnsAt(text, offset);
|
||||
const shared = placement.scope === 'shared';
|
||||
|
||||
const suggestions = DATA_TRANSFORMS.map((t, i) => ({
|
||||
const suggestions = DATA_TRANSFORMS.map((t, i) =>
|
||||
scaffoldSuggestion(slot, i, {
|
||||
label: t.label,
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
detail: t.detail,
|
||||
body: t.step(fields),
|
||||
// A shared step (on a composition parent) transforms the data feeding every
|
||||
// child view, not just one — surface that so the placement isn't a surprise.
|
||||
documentation: shared
|
||||
@@ -124,12 +73,8 @@ export function configureSpecTransformScaffold(): void {
|
||||
'_Applies to all views below — this `transform` sits on a composition parent._',
|
||||
}
|
||||
: undefined,
|
||||
insertText: `${lead}${t.step(fields)}${trail}`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
// Keep catalog order and float the scaffolds above the schema's generic items.
|
||||
sortText: `0${String(i).padStart(2, '0')}`,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
@@ -152,19 +97,16 @@ function runAddStep(
|
||||
const transform = DATA_TRANSFORMS.find((t) => t.id === id);
|
||||
if (!site?.array || !transform) return;
|
||||
|
||||
const insertOffset = site.array.offset + site.array.length - 1; // just before the ']'
|
||||
const { lead, trail } = arrayAffixes(text, insertOffset, insertOffset);
|
||||
const fields = boundColumnsAt(text, insertOffset);
|
||||
insertSnippetAt(editor, insertOffset, `${lead}${transform.step(fields)}${trail}`);
|
||||
const fields = boundColumnsAt(text, site.array.offset);
|
||||
const edit = appendEntryEdit(text, site.array, transform.step(fields));
|
||||
insertSnippetAt(editor, edit.offset, edit.snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an empty `transform: []` as the view's first property and drop the cursor
|
||||
* between its brackets (`$0`), so the per-step lenses appear next and the first step
|
||||
* fills the array in place. The array is written **inline** — no interior newlines for
|
||||
* the snippet engine to re-base — so it lands cleanly indented off the view line's own
|
||||
* nesting. Separated from the following property with a comma only when one follows (a
|
||||
* view with no other key takes none), so the result stays valid JSON.
|
||||
* fills the array in place. Placement, indentation, and the comma are
|
||||
* `createArrayPropertyEdit`'s tested math.
|
||||
*/
|
||||
function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset: number): void {
|
||||
const model = editor.getModel();
|
||||
@@ -173,15 +115,9 @@ function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset
|
||||
const site = transformSiteAt(text, viewOffset + 1);
|
||||
if (!site || site.array) return; // gone, or already has a pipeline
|
||||
|
||||
const brace = site.view.offset; // the view object's opening '{'
|
||||
const tab = model.getOptions().tabSize || 2;
|
||||
// Indent off the brace *line's* leading whitespace, not the brace column — a view
|
||||
// the compact formatter kept inline (mid-line '{') would otherwise over-indent.
|
||||
const braceLine = model.getPositionAt(brace).lineNumber;
|
||||
const baseIndent = /^\s*/.exec(model.getLineContent(braceLine))?.[0].length ?? 0;
|
||||
const indent = ' '.repeat(baseIndent + tab);
|
||||
const trail = neighbour(text, brace + 1, 1) === '}' ? '' : ',';
|
||||
insertSnippetAt(editor, brace + 1, `\n${indent}"transform": [$0]${trail}`);
|
||||
const edit = createArrayPropertyEdit(text, site.view.offset, tab, 'transform', '$0');
|
||||
insertSnippetAt(editor, edit.offset, edit.snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,63 +137,19 @@ export function installSpecTransformScaffoldCodeLens(
|
||||
runAddStep(editor, id, arrayOffset),
|
||||
);
|
||||
|
||||
const lens = (
|
||||
line: number,
|
||||
title: string,
|
||||
id: string | null,
|
||||
args: unknown[],
|
||||
): monaco.languages.CodeLens => ({
|
||||
range: new monaco.Range(line, 1, line, 1),
|
||||
command: { id: id ?? '', title, arguments: args },
|
||||
});
|
||||
|
||||
// The lenses follow the cursor's view, so signal a refresh when that changes —
|
||||
// keyed to the view and its step count so typing within one view doesn't churn them.
|
||||
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
|
||||
const provider: monaco.languages.CodeLensProvider = {
|
||||
onDidChange: onDidChange.event,
|
||||
provideCodeLenses(model) {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
|
||||
const pos = editor.getPosition();
|
||||
if (!pos) return { lenses: [], dispose() {} };
|
||||
const site = transformSiteAt(model.getValue(), model.getOffsetAt(pos));
|
||||
if (!site) return { lenses: [], dispose() {} };
|
||||
|
||||
const lenses: monaco.languages.CodeLens[] = [];
|
||||
return installCursorLens<TransformSite>(editor, {
|
||||
resolve: transformSiteAt,
|
||||
// Refresh when the enclosing view or its step count changes, not on every keystroke.
|
||||
keyOf: (site) => JSON.stringify([site.view.offset, site.array?.count ?? -1]),
|
||||
lensesOf: (site, model, lens) => {
|
||||
if (!site.array) {
|
||||
const line = model.getPositionAt(site.view.offset).lineNumber;
|
||||
lenses.push(lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset]));
|
||||
} else {
|
||||
return [lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset])];
|
||||
}
|
||||
const line = model.getPositionAt(site.array.offset).lineNumber;
|
||||
for (const id of COMMON_STEP_IDS) {
|
||||
lenses.push(lens(line, `$(add) ${id}`, addStepCmd, [id, site.array.offset]));
|
||||
}
|
||||
}
|
||||
return { lenses, dispose() {} };
|
||||
return COMMON_STEP_IDS.map((id) =>
|
||||
lens(line, `$(add) ${id}`, addStepCmd, [id, site.array!.offset]),
|
||||
);
|
||||
},
|
||||
};
|
||||
const registration = monaco.languages.registerCodeLensProvider('json', provider);
|
||||
|
||||
let lastKey = '';
|
||||
const cursorSub = editor.onDidChangeCursorPosition(() => {
|
||||
const model = editor.getModel();
|
||||
const pos = editor.getPosition();
|
||||
const site =
|
||||
model && pos && useSnippetStore.getState().editorView === 'draft'
|
||||
? transformSiteAt(model.getValue(), model.getOffsetAt(pos))
|
||||
: null;
|
||||
const key = site ? JSON.stringify([site.view.offset, site.array?.count ?? -1]) : '';
|
||||
if (key !== lastKey) {
|
||||
lastKey = key;
|
||||
onDidChange.fire(provider);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
cursorSub.dispose();
|
||||
onDidChange.dispose();
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Ajv, { type ValidateFunction } from 'ajv';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
|
||||
import {
|
||||
arrayAffixes,
|
||||
DATA_TRANSFORMS,
|
||||
snippetToPlain,
|
||||
transformPlacementAt,
|
||||
transformSiteAt,
|
||||
type TypedField,
|
||||
} from './spec-data-transforms';
|
||||
import { snippetToPlain } from './spec-snippet';
|
||||
|
||||
/** Compile the bundled Vega-Lite schema once so every step can be validated for real. */
|
||||
const validateSpec: ValidateFunction = new Ajv({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(vegaLiteSchema as object);
|
||||
|
||||
/** Place a `|` marker in the source, strip it, and classify at that offset. */
|
||||
function placeAt(marked: string) {
|
||||
@@ -115,17 +122,22 @@ describe('transformSiteAt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog — every step snippet is valid JSON once tab stops are stripped', () => {
|
||||
describe('catalog — every step snippet, seeded, is valid Vega-Lite', () => {
|
||||
/** A unit spec carrying the stripped step, for real-schema validation. */
|
||||
const withStep = (snippet: string) => ({
|
||||
data: { values: [{ price: 1, origin: 'A' }] },
|
||||
mark: 'point',
|
||||
encoding: {},
|
||||
transform: [JSON.parse(snippetToPlain(snippet)) as unknown],
|
||||
});
|
||||
for (const t of DATA_TRANSFORMS) {
|
||||
it(`${t.id} (with fields in scope)`, () => {
|
||||
expect(() => {
|
||||
JSON.parse(snippetToPlain(t.step(FIELDS)));
|
||||
}).not.toThrow();
|
||||
const ok = validateSpec(withStep(t.step(FIELDS)));
|
||||
expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
|
||||
});
|
||||
it(`${t.id} (no data in scope — generic fallbacks)`, () => {
|
||||
expect(() => {
|
||||
JSON.parse(snippetToPlain(t.step([])));
|
||||
}).not.toThrow();
|
||||
const ok = validateSpec(withStep(t.step([])));
|
||||
expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -155,38 +167,6 @@ describe('catalog — field-typed defaults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayAffixes — keeps a spliced-in element valid JSON', () => {
|
||||
const at = (marked: string) => {
|
||||
const i = marked.indexOf('|');
|
||||
return arrayAffixes(marked.replace('|', ''), i, i);
|
||||
};
|
||||
it('no commas in an empty array', () => {
|
||||
expect(at('[|]')).toEqual({ lead: '', trail: '' });
|
||||
});
|
||||
it('leading comma when appended after an element', () => {
|
||||
expect(at('[{"a":1}|]')).toEqual({ lead: ', ', trail: '' });
|
||||
});
|
||||
it('no leading comma right after a separator comma', () => {
|
||||
expect(at('[{"a":1},|]')).toEqual({ lead: '', trail: '' });
|
||||
});
|
||||
it('trailing comma when inserted before an element', () => {
|
||||
expect(at('[|{"a":1}]')).toEqual({ lead: '', trail: ',' });
|
||||
});
|
||||
it('both when squeezed between two elements without commas', () => {
|
||||
expect(at('[{"a":1}|{"b":2}]')).toEqual({ lead: ', ', trail: ',' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippetToPlain', () => {
|
||||
it('reduces tab stops to their default text', () => {
|
||||
expect(snippetToPlain('"a": "${1:x}", "b": ${2:true}')).toBe('"a": "x", "b": true');
|
||||
});
|
||||
|
||||
it('drops an empty final tab stop', () => {
|
||||
expect(snippetToPlain('foo${0}')).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
function byId(id: string) {
|
||||
const t = DATA_TRANSFORMS.find((x) => x.id === id);
|
||||
if (!t) throw new Error(`no transform ${id}`);
|
||||
|
||||
@@ -282,42 +282,3 @@ export const DATA_TRANSFORMS: readonly DataTransform[] = [
|
||||
`{ "lookup": "\${1:key}", "from": { "data": { "name": "\${2:dataset}" }, "key": "\${3:key}", "fields": ["\${4:field}"] } }`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The plain form of a snippet: strip tab stops down to their default text
|
||||
* (`${1:price}` → `price`, `${0}` → ''). Keeps the catalog testable as valid JSON
|
||||
* without a Monaco snippet engine.
|
||||
*/
|
||||
export function snippetToPlain(snippet: string): string {
|
||||
return snippet.replace(
|
||||
/\$\{(\d+)(?::([^}]*))?\}/g,
|
||||
(_m: string, _n: string, defText?: string) => defText ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/** The whitespace-skipping neighbour of an offset, in the given direction (or '' at the edge). */
|
||||
export function neighbour(text: string, from: number, step: -1 | 1): string {
|
||||
let i = from;
|
||||
while (i >= 0 && i < text.length && /\s/.test(text[i])) i += step;
|
||||
return i >= 0 && i < text.length ? text[i] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading/trailing commas needed to splice a new element in at `[start, end)` of a
|
||||
* JSON array: a comma before it unless the preceding element already ends in one (or
|
||||
* it is the first), and after it unless the following char closes the array (or a
|
||||
* comma is already there). Keeps the array valid whether the slot is empty, between
|
||||
* elements, or appended after one with no trailing comma. Pure text math — lives here
|
||||
* (with `snippetToPlain`) so its edge cases are unit-testable off the Monaco glue.
|
||||
*/
|
||||
export function arrayAffixes(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): { lead: string; trail: string } {
|
||||
const prev = neighbour(text, start - 1, -1);
|
||||
const next = neighbour(text, end, 1);
|
||||
const lead = prev !== '' && prev !== '[' && prev !== ',' ? ', ' : '';
|
||||
const trail = next !== '' && next !== ']' && next !== ',' ? ',' : '';
|
||||
return { lead, trail };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import Ajv, { type ValidateFunction } from 'ajv';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
|
||||
import { PARAMS, type ParamField, paramPlacementAt, paramSiteAt } from './spec-params';
|
||||
import { snippetToPlain } from './spec-snippet';
|
||||
|
||||
/** Compile the bundled Vega-Lite schema once so every param can be validated for real. */
|
||||
const validateSpec: ValidateFunction = new Ajv({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(vegaLiteSchema as object);
|
||||
|
||||
/** Place a `|` marker, strip it, and resolve the param site at that offset. */
|
||||
function siteAt(marked: string) {
|
||||
const offset = marked.indexOf('|');
|
||||
return paramSiteAt(marked.replace('|', ''), offset);
|
||||
}
|
||||
|
||||
/** Place a `|` marker, strip it, and classify the param slot at that offset. */
|
||||
function placeAt(marked: string) {
|
||||
const offset = marked.indexOf('|');
|
||||
return paramPlacementAt(marked.replace('|', ''), offset);
|
||||
}
|
||||
|
||||
const FIELDS: readonly ParamField[] = [
|
||||
{ name: 'region', type: 'string', extent: null },
|
||||
{ name: 'price', type: 'number', extent: { min: 12, max: 988 } },
|
||||
{ name: 'date', type: 'date', extent: null },
|
||||
];
|
||||
|
||||
/** The shape of a parsed parameter entry — enough to assert on without `any`. */
|
||||
interface ParsedParam {
|
||||
name: string;
|
||||
value?: unknown;
|
||||
bind?: { input: string; min?: number; max?: number; step?: number; options?: unknown[] };
|
||||
select?: { type: string; fields?: string[]; encodings?: string[] };
|
||||
}
|
||||
|
||||
/** Strip a param snippet to its defaults and parse it as a plain (typed) entry. */
|
||||
function plain(snippet: string): ParsedParam {
|
||||
return JSON.parse(snippetToPlain(snippet)) as ParsedParam;
|
||||
}
|
||||
|
||||
describe('parameter catalog', () => {
|
||||
it('every parameter, seeded, is valid Vega-Lite', () => {
|
||||
const data = { values: [{ region: 'A', price: 1 }] };
|
||||
for (const p of PARAMS) {
|
||||
const ok = validateSpec({
|
||||
data,
|
||||
mark: 'point',
|
||||
encoding: {},
|
||||
params: [plain(p.param(FIELDS))],
|
||||
});
|
||||
expect(ok, `${p.id}: ${JSON.stringify(validateSpec.errors?.[0])}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('variable parameters carry a bind, selections carry a select', () => {
|
||||
for (const p of PARAMS) {
|
||||
const entry = plain(p.param(FIELDS));
|
||||
if (p.family === 'variable') {
|
||||
expect(entry.bind, p.id).toBeDefined();
|
||||
expect(entry.select, p.id).toBeUndefined();
|
||||
} else {
|
||||
expect(entry.select, p.id).toBeDefined();
|
||||
expect(entry.bind, p.id).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a slider is seeded from the numeric column extent', () => {
|
||||
const slider = PARAMS.find((p) => p.id === 'slider')!;
|
||||
const entry = plain(slider.param(FIELDS));
|
||||
expect(entry.bind?.min).toBe(12);
|
||||
expect(entry.bind?.max).toBe(988);
|
||||
// Step ~ 1/100 of the range, snapped to a nice number; value is the midpoint on it.
|
||||
expect(entry.bind?.step).toBe(10);
|
||||
expect(entry.value).toBe(500);
|
||||
});
|
||||
|
||||
it('a slider falls back to 0–100 with no numeric column in scope', () => {
|
||||
const slider = PARAMS.find((p) => p.id === 'slider')!;
|
||||
expect(plain(slider.param([])).bind).toMatchObject({
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
input: 'range',
|
||||
});
|
||||
});
|
||||
|
||||
it('a point selection is seeded from a categorical column', () => {
|
||||
const point = PARAMS.find((p) => p.id === 'point')!;
|
||||
expect(plain(point.param(FIELDS)).select?.fields).toEqual(['region']);
|
||||
expect(plain(point.param([])).select?.fields).toEqual(['category']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paramSiteAt', () => {
|
||||
it('returns null for a non-object root', () => {
|
||||
expect(paramSiteAt('[1, 2]', 1)).toBeNull();
|
||||
expect(paramSiteAt('', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('a single-view spec shares one host for both families', () => {
|
||||
const site = siteAt('{ "mark": "ba|r", "encoding": {} }');
|
||||
expect(site).not.toBeNull();
|
||||
expect(site!.unit).not.toBeNull();
|
||||
// Root is the unit, so both families anchor to the same object.
|
||||
expect(site!.unit!.view.offset).toBe(site!.root.view.offset);
|
||||
expect(site!.root.array).toBeNull(); // no params yet
|
||||
});
|
||||
|
||||
it('reports an existing params array with its element count', () => {
|
||||
const site = siteAt('{ "mark": "point", "params": [{ "name": "x" }], "enc|oding": {} }');
|
||||
expect(site!.root.array?.count).toBe(1);
|
||||
expect(site!.unit!.array?.count).toBe(1);
|
||||
});
|
||||
|
||||
it('a composed spec anchors selection to the enclosing unit, variable to the root', () => {
|
||||
const spec = `{
|
||||
"hconcat": [
|
||||
{ "mark": "ba|r", "encoding": {} },
|
||||
{ "mark": "line", "encoding": {} }
|
||||
]
|
||||
}`;
|
||||
const site = siteAt(spec);
|
||||
expect(site!.root.view.offset).toBe(0); // the hconcat root
|
||||
expect(site!.unit).not.toBeNull();
|
||||
// The unit is the first child, a different object than the root.
|
||||
expect(site!.unit!.view.offset).not.toBe(site!.root.view.offset);
|
||||
});
|
||||
|
||||
it('has no unit when the cursor is in the container but no leaf view', () => {
|
||||
const spec = `{
|
||||
"hconcat": [|
|
||||
{ "mark": "bar", "encoding": {} }
|
||||
]
|
||||
}`;
|
||||
const site = siteAt(spec);
|
||||
expect(site!.root.view.offset).toBe(0);
|
||||
expect(site!.unit).toBeNull();
|
||||
});
|
||||
|
||||
it('a layer container with a shared encoding is not a unit (its params are illegal)', () => {
|
||||
// Nested LayerSpec carries `encoding` but the schema gives it no `params`.
|
||||
const spec = '{ "vconcat": [{ "layer": [{ "mark": "bar" }], "encoding": { "x|": {} } }] }';
|
||||
expect(siteAt(spec)!.unit).toBeNull();
|
||||
});
|
||||
|
||||
it('a leaf inside a layer is still a unit', () => {
|
||||
const spec = '{ "vconcat": [{ "layer": [{ "mark": "b|ar" }], "encoding": {} }] }';
|
||||
const site = siteAt(spec);
|
||||
expect(site!.unit).not.toBeNull();
|
||||
expect(site!.unit!.view.offset).toBe(spec.replace('|', '').indexOf('{ "mark"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('paramPlacementAt', () => {
|
||||
it('classifies a root params slot as atRoot', () => {
|
||||
expect(placeAt('{ "mark": "point", "params": [|] }')).toEqual({ kind: 'slot', atRoot: true });
|
||||
});
|
||||
|
||||
it('classifies a nested unit params slot as not atRoot', () => {
|
||||
const spec = '{ "hconcat": [{ "mark": "bar", "params": [|] }] }';
|
||||
expect(placeAt(spec)).toEqual({ kind: 'slot', atRoot: false });
|
||||
});
|
||||
|
||||
it('is none on a property key or outside any params array', () => {
|
||||
expect(placeAt('{ "par|ams": [] }').kind).toBe('none');
|
||||
expect(placeAt('{ "mark": "po|int" }').kind).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Parameter scaffolding (docs/architecture/08 → editor augmentation) — the sibling of
|
||||
* `spec-data-transforms`, for a spec's `params` rather than its `transform` pipeline.
|
||||
* It offers the Vega-Lite parameters the editor can pre-fill: **variable** parameters
|
||||
* (an input widget — slider, dropdown, radio, checkbox — bound via `bind`) and
|
||||
* **selection** parameters (point, interval — user interaction on the chart, via
|
||||
* `select`). Text/Monaco handling (ranges, insertion, the CodeLens) is the service's
|
||||
* job (`app/services/spec-param-scaffold`); this owns the facts the schema can't
|
||||
* supply — *where* each family legally attaches and *what* a data-seeded skeleton
|
||||
* looks like.
|
||||
*
|
||||
* **Where a parameter can legally go (grammar-confirmed against the bundled VL
|
||||
* schema).** Unlike a data `transform` — which sits on every view node — `params`
|
||||
* has two distinct homes, and the two families do *not* share them:
|
||||
*
|
||||
* - **top-level `params[]`** (on the root spec, whatever its kind) takes
|
||||
* `TopLevelParameter` = variable *or* selection. This is the only place a
|
||||
* **variable** parameter is legal: a nested unit's `params` accepts selections
|
||||
* only, so a slider must live at the root (where its `bind` widget is a global
|
||||
* input any view's `filter` can read).
|
||||
* - **a unit's `params[]`** (`mark`/`encoding` node) takes `SelectionParameter` —
|
||||
* selections only. A **selection** belongs on the unit whose marks it reads, so
|
||||
* it anchors to the nearest enclosing unit; for a single-view spec the root *is*
|
||||
* that unit, so both families land in the one array.
|
||||
*
|
||||
* `paramSiteAt` resolves both homes for the CodeLens (root for variable, nearest unit
|
||||
* for selection); `paramPlacementAt` classifies a `params[]` slot for the completion,
|
||||
* flagging whether it is the root array (both families) or a nested unit's (selections
|
||||
* only). Both read `jsonc-parser`'s error-tolerant tree so they hold mid-edit. The
|
||||
* catalog builders take the columns in scope (resolved by the service via
|
||||
* `active-dataset`) and seed each `${n:default}` tab stop — a slider's bounds from the
|
||||
* numeric field's extent, a point selection's field from a categorical column.
|
||||
*/
|
||||
|
||||
import { findNodeAtOffset, getLocation, parseTree, type Node } from 'jsonc-parser';
|
||||
import { ARRAY_COMPOSITIONS } from './spec-transforms';
|
||||
import type { ColumnType } from './type-inference';
|
||||
|
||||
/** A column in scope at the cursor, with its type and (for numeric columns) its range. */
|
||||
export interface ParamField {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
/** Min/max over the column's values, for numeric columns (seeds slider bounds); null otherwise. */
|
||||
extent: { min: number; max: number } | null;
|
||||
}
|
||||
|
||||
/** A byte range into the spec text. */
|
||||
interface ByteRange {
|
||||
offset: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/** A `params[]` array's range and element count, when one exists. */
|
||||
type ParamArray = (ByteRange & { count: number }) | null;
|
||||
|
||||
/**
|
||||
* Where one family of parameter attaches: the host object's range (where an
|
||||
* `+ Add parameter` lens sits and a fresh `params: []` is created) plus that object's
|
||||
* existing `params[]` (where the per-kind lenses sit and a new entry is spliced in).
|
||||
*/
|
||||
export interface ParamHost {
|
||||
view: ByteRange;
|
||||
array: ParamArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two homes a parameter can take at the cursor:
|
||||
* - `root` — the top-level spec, where variable parameters live (always present);
|
||||
* - `unit` — the nearest enclosing unit, where selection parameters live, or null
|
||||
* when the cursor is in no unit. When the root is itself a unit (a single-view
|
||||
* spec) `unit` shares the root's `view.offset`, so the service renders one group.
|
||||
*/
|
||||
export interface ParamSite {
|
||||
root: ParamHost;
|
||||
unit: ParamHost | null;
|
||||
}
|
||||
|
||||
/** Where the cursor sits relative to a `params[]` slot:
|
||||
* - `slot` — in a `params` array element position (offer the catalog), tagged with
|
||||
* whether that array is the root's (`atRoot` — both families) or a unit's
|
||||
* (selections only);
|
||||
* - `none` — anywhere else (including on a property key, which the schema owns). */
|
||||
export type ParamPlacement = { kind: 'slot'; atRoot: boolean } | { kind: 'none' };
|
||||
|
||||
/** The property keys of an object node. */
|
||||
// TODO: duplicated with `spec-data-transforms` (objectKeys byte-identical;
|
||||
// paramsArrayNode mirrors transformArrayNode modulo the key) — worth a shared
|
||||
// jsonc-node helper home; see the subtraction cut list in
|
||||
// docs/exploration/engineering-review-2026-07.md.
|
||||
function objectKeys(node: Node | null): string[] {
|
||||
if (!node?.children) return [];
|
||||
const keys: string[] = [];
|
||||
for (const prop of node.children) {
|
||||
const key = prop.type === 'property' ? prop.children?.[0] : undefined;
|
||||
if (key?.type === 'string' && typeof key.value === 'string') keys.push(key.value);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Keys that make an object a composition container rather than a unit. A container
|
||||
* can carry a shared `encoding` (a layer's, say), but the schema gives it no
|
||||
* `params` — only units and the root take them. */
|
||||
const COMPOSITION_KEYS = [...ARRAY_COMPOSITIONS, 'facet', 'repeat', 'spec'];
|
||||
|
||||
/** A unit view — the leaf a selection attaches to — carries `mark` (or, mid-edit,
|
||||
* `encoding`) and no composition key. */
|
||||
function isUnitNode(node: Node): boolean {
|
||||
const keys = objectKeys(node);
|
||||
if (COMPOSITION_KEYS.some((k) => keys.includes(k))) return false;
|
||||
return keys.includes('mark') || keys.includes('encoding');
|
||||
}
|
||||
|
||||
/** The `params` array node of an object, or null when it has none. */
|
||||
function paramsArrayNode(node: Node): Node | null {
|
||||
for (const prop of node.children ?? []) {
|
||||
if (prop.type === 'property' && prop.children?.[0]?.value === 'params') {
|
||||
const value = prop.children[1];
|
||||
return value?.type === 'array' ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A host descriptor for an object node: its range plus its `params[]` range/count. */
|
||||
function hostFor(node: Node): ParamHost {
|
||||
const array = paramsArrayNode(node);
|
||||
return {
|
||||
view: { offset: node.offset, length: node.length },
|
||||
array: array
|
||||
? { offset: array.offset, length: array.length, count: array.children?.length ?? 0 }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve both parameter homes at `offset`: the root spec (for variable parameters)
|
||||
* and the nearest enclosing unit (for selections), or null when the text has no
|
||||
* object root. The CodeLens reads this to place its lenses and to splice a new entry.
|
||||
*/
|
||||
export function paramSiteAt(text: string, offset: number): ParamSite | null {
|
||||
const tree = parseTree(text);
|
||||
if (!tree || tree.type !== 'object') return null;
|
||||
|
||||
let unit: ParamHost | null = null;
|
||||
let node: Node | undefined = findNodeAtOffset(tree, offset, true);
|
||||
while (node) {
|
||||
if (node.type === 'object' && isUnitNode(node)) {
|
||||
unit = hostFor(node);
|
||||
break;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
return { root: hostFor(tree), unit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the cursor's `params` context from the error-tolerant tree (so it holds
|
||||
* mid-edit). A slot is a `params` array element position (`[ …, "params", <index> ]`)
|
||||
* that is not a property key; `atRoot` when that array sits directly on the root
|
||||
* (path is exactly `["params", n]`) — the only place variable parameters are legal.
|
||||
*/
|
||||
export function paramPlacementAt(text: string, offset: number): ParamPlacement {
|
||||
const { path, isAtPropertyKey } = getLocation(text, offset);
|
||||
if (
|
||||
isAtPropertyKey ||
|
||||
path.length < 2 ||
|
||||
path[path.length - 2] !== 'params' ||
|
||||
typeof path[path.length - 1] !== 'number'
|
||||
) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return { kind: 'slot', atRoot: path.length === 2 };
|
||||
}
|
||||
|
||||
// ─── Field-seeded defaults ────────────────────────────────────────────────────────
|
||||
|
||||
/** The first numeric column carrying a known extent (for slider bounds), or null. */
|
||||
function numericField(fields: readonly ParamField[]): ParamField | null {
|
||||
return fields.find((f) => f.type === 'number' && f.extent) ?? null;
|
||||
}
|
||||
|
||||
/** The first categorical column (for a point selection's field), or null. */
|
||||
function categoricalField(fields: readonly ParamField[]): string | null {
|
||||
return fields.find((f) => f.type === 'string' || f.type === 'boolean')?.name ?? null;
|
||||
}
|
||||
|
||||
/** Trim binary-float noise from a computed number for clean snippet text. */
|
||||
function fmt(n: number): string {
|
||||
return String(Math.round(n * 1e6) / 1e6);
|
||||
}
|
||||
|
||||
/** A "nice" step (1/2/5 × 10ⁿ) near 1/100 of the range, so a slider has ~50–100 stops. */
|
||||
function niceStep(range: number): number {
|
||||
if (!(range > 0)) return 1;
|
||||
const raw = range / 100;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||
const norm = raw / mag; // 1 ≤ norm < 10
|
||||
const nice = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10;
|
||||
return nice * mag;
|
||||
}
|
||||
|
||||
/** Slider bounds seeded from the numeric field's extent, or a plain 0–100 default. */
|
||||
function sliderBounds(fields: readonly ParamField[]): {
|
||||
min: string;
|
||||
max: string;
|
||||
value: string;
|
||||
step: string;
|
||||
} {
|
||||
const f = numericField(fields);
|
||||
if (!f?.extent) return { min: '0', max: '100', value: '50', step: '1' };
|
||||
const { min, max } = f.extent;
|
||||
const step = niceStep(max - min);
|
||||
const value = Math.round((min + max) / 2 / step) * step;
|
||||
return { min: fmt(min), max: fmt(max), value: fmt(value), step: fmt(step) };
|
||||
}
|
||||
|
||||
// ─── The parameter catalog ──────────────────────────────────────────────────────
|
||||
|
||||
/** The two kinds of parameter: a bound input widget, or a chart-interaction selection. */
|
||||
export type ParamFamily = 'variable' | 'selection';
|
||||
|
||||
/** One parameter the editor can scaffold as a `params[]` entry. */
|
||||
export interface ParamKind {
|
||||
id: string;
|
||||
/** Completion label. */
|
||||
label: string;
|
||||
/** One-line description (completion detail). */
|
||||
detail: string;
|
||||
/** Variable parameters are root-only; selections attach to a unit. */
|
||||
family: ParamFamily;
|
||||
/** The entry snippet: compact JSON with `${n:default}` tab stops. */
|
||||
param(fields: readonly ParamField[]): string;
|
||||
}
|
||||
|
||||
/** The scaffoldable parameters, variable widgets first, then selections. */
|
||||
export const PARAMS: readonly ParamKind[] = [
|
||||
{
|
||||
id: 'slider',
|
||||
label: 'slider',
|
||||
detail: 'A numeric range input bound to a variable',
|
||||
family: 'variable',
|
||||
param: (f) => {
|
||||
const b = sliderBounds(f);
|
||||
return `{ "name": "\${1:threshold}", "value": \${2:${b.value}}, "bind": { "input": "range", "min": \${3:${b.min}}, "max": \${4:${b.max}}, "step": \${5:${b.step}} } }`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dropdown',
|
||||
label: 'dropdown',
|
||||
detail: 'A select menu bound to a variable',
|
||||
family: 'variable',
|
||||
param: () =>
|
||||
`{ "name": "\${1:choice}", "value": "\${2:A}", "bind": { "input": "select", "options": ["\${2:A}", "\${3:B}", "\${4:C}"] } }`,
|
||||
},
|
||||
{
|
||||
id: 'radio',
|
||||
label: 'radio',
|
||||
detail: 'A radio-button group bound to a variable',
|
||||
family: 'variable',
|
||||
param: () =>
|
||||
`{ "name": "\${1:choice}", "value": "\${2:A}", "bind": { "input": "radio", "options": ["\${2:A}", "\${3:B}", "\${4:C}"] } }`,
|
||||
},
|
||||
{
|
||||
id: 'checkbox',
|
||||
label: 'checkbox',
|
||||
detail: 'A boolean toggle bound to a variable',
|
||||
family: 'variable',
|
||||
param: () => `{ "name": "\${1:toggle}", "value": \${2:true}, "bind": { "input": "checkbox" } }`,
|
||||
},
|
||||
{
|
||||
id: 'point',
|
||||
label: 'point',
|
||||
detail: 'Click to select marks sharing a field value',
|
||||
family: 'selection',
|
||||
param: (f) =>
|
||||
`{ "name": "\${1:highlight}", "select": { "type": "point", "fields": ["\${2:${categoricalField(f) ?? 'category'}}"] } }`,
|
||||
},
|
||||
{
|
||||
id: 'interval',
|
||||
label: 'interval',
|
||||
detail: 'Drag a brush over the chart to select a range',
|
||||
family: 'selection',
|
||||
param: () =>
|
||||
`{ "name": "\${1:brush}", "select": { "type": "interval", "encodings": ["\${2:x}", "\${3:y}"] } }`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendEntryEdit,
|
||||
arrayAffixes,
|
||||
createArrayPropertyEdit,
|
||||
snippetToPlain,
|
||||
type SnippetEdit,
|
||||
} from './spec-snippet';
|
||||
|
||||
/** Apply a computed edit to the text, tab stops stripped to their defaults. */
|
||||
function apply(text: string, edit: SnippetEdit): string {
|
||||
return text.slice(0, edit.offset) + snippetToPlain(edit.snippet) + text.slice(edit.offset);
|
||||
}
|
||||
|
||||
describe('snippetToPlain', () => {
|
||||
it('reduces tab stops to their default text', () => {
|
||||
expect(snippetToPlain('"a": "${1:x}", "b": ${2:true}')).toBe('"a": "x", "b": true');
|
||||
});
|
||||
|
||||
it('drops an empty final tab stop, braced or bare', () => {
|
||||
expect(snippetToPlain('foo${0}')).toBe('foo');
|
||||
expect(snippetToPlain('[$0]')).toBe('[]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayAffixes — keeps a spliced-in element valid JSON', () => {
|
||||
const at = (marked: string) => {
|
||||
const i = marked.indexOf('|');
|
||||
return arrayAffixes(marked.replace('|', ''), i, i);
|
||||
};
|
||||
it('no commas in an empty array', () => {
|
||||
expect(at('[|]')).toEqual({ lead: '', trail: '' });
|
||||
});
|
||||
it('leading comma when appended after an element', () => {
|
||||
expect(at('[{"a":1}|]')).toEqual({ lead: ', ', trail: '' });
|
||||
});
|
||||
it('no leading comma right after a separator comma', () => {
|
||||
expect(at('[{"a":1},|]')).toEqual({ lead: '', trail: '' });
|
||||
});
|
||||
it('trailing comma when inserted before an element', () => {
|
||||
expect(at('[|{"a":1}]')).toEqual({ lead: '', trail: ',' });
|
||||
});
|
||||
it('both when squeezed between two elements without commas', () => {
|
||||
expect(at('[{"a":1}|{"b":2}]')).toEqual({ lead: ', ', trail: ',' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendEntryEdit — applied, the array parses with the entry last', () => {
|
||||
/** Append into the `transform` array of `doc` and parse the result. */
|
||||
function appended(doc: string): unknown[] {
|
||||
const arrayOffset = doc.indexOf('[');
|
||||
const array = { offset: arrayOffset, length: doc.lastIndexOf(']') - arrayOffset + 1 };
|
||||
const out = apply(doc, appendEntryEdit(doc, array, '{ "x": ${1:9} }'));
|
||||
return (JSON.parse(out) as { t: unknown[] }).t;
|
||||
}
|
||||
|
||||
it('into an empty array', () => {
|
||||
expect(appended('{"t":[]}')).toEqual([{ x: 9 }]);
|
||||
});
|
||||
|
||||
it('after an element with no trailing comma', () => {
|
||||
expect(appended('{"t":[{"a":1}]}')).toEqual([{ a: 1 }, { x: 9 }]);
|
||||
});
|
||||
|
||||
it('across a multi-line formatted array', () => {
|
||||
expect(appended('{"t":[\n { "a": 1 },\n { "b": 2 }\n]}')).toEqual([
|
||||
{ a: 1 },
|
||||
{ b: 2 },
|
||||
{ x: 9 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createArrayPropertyEdit — applied, the object parses with the new property', () => {
|
||||
it('adds a comma before a following property', () => {
|
||||
const doc = '{\n "mark": "bar"\n}';
|
||||
const out = apply(doc, createArrayPropertyEdit(doc, 0, 2, 'params', '{ "n": ${1:1} }'));
|
||||
expect(JSON.parse(out)).toEqual({ params: [{ n: 1 }], mark: 'bar' });
|
||||
});
|
||||
|
||||
it('takes no comma in an otherwise-empty object', () => {
|
||||
const doc = '{}';
|
||||
const out = apply(doc, createArrayPropertyEdit(doc, 0, 2, 'transform', '$0'));
|
||||
expect(JSON.parse(out)).toEqual({ transform: [] });
|
||||
expect(out).toBe('{\n "transform": []}');
|
||||
});
|
||||
|
||||
it('indents off the brace line, not the brace column (compact-formatted host)', () => {
|
||||
const doc = '{\n "hconcat": [\n { "mark": "bar" }\n ]\n}';
|
||||
const brace = doc.indexOf('{ "mark"');
|
||||
const edit = createArrayPropertyEdit(doc, brace, 2, 'params', '{ "n": ${1:1} }');
|
||||
// One tab past the line's 4-space indent, not past the mid-line brace column.
|
||||
expect(edit.snippet.startsWith('\n "params"')).toBe(true);
|
||||
expect(JSON.parse(apply(doc, edit))).toEqual({
|
||||
hconcat: [{ params: [{ n: 1 }], mark: 'bar' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('honors the tab size', () => {
|
||||
const edit = createArrayPropertyEdit('{}', 0, 4, 'params', '$0');
|
||||
expect(edit.snippet.startsWith('\n "params"')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Snippet-insertion text math shared by the editor scaffolds (docs/architecture/08 →
|
||||
* editor augmentation). The scaffold services (`spec-transform-scaffold`,
|
||||
* `spec-param-scaffold`) splice `${n:default}` snippet templates into the spec text;
|
||||
* a wrong offset or comma here writes invalid JSON into the user's editor, so the
|
||||
* computations are pure functions of the text — `(text, site) → { offset, snippet }` —
|
||||
* and table-tested here rather than living inside the Monaco glue. `snippetToPlain`
|
||||
* strips a template to its default text so every computed edit (and every catalog
|
||||
* skeleton) can be round-tripped through `JSON.parse` in tests.
|
||||
*/
|
||||
|
||||
/** A byte range into the spec text. */
|
||||
interface ByteRange {
|
||||
offset: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/** A computed text edit: the snippet template to insert and where. */
|
||||
export interface SnippetEdit {
|
||||
offset: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain form of a snippet: strip tab stops down to their default text
|
||||
* (`${1:price}` → `price`; the default-less `${0}` and bare `$0` → ''). Keeps the
|
||||
* catalogs and the computed edits testable as valid JSON without a Monaco snippet engine.
|
||||
*/
|
||||
export function snippetToPlain(snippet: string): string {
|
||||
return snippet
|
||||
.replace(
|
||||
/\$\{(\d+)(?::([^}]*))?\}/g,
|
||||
(_m: string, _n: string, defText?: string) => defText ?? '',
|
||||
)
|
||||
.replace(/\$\d+/g, '');
|
||||
}
|
||||
|
||||
/** The whitespace-skipping neighbour of an offset, in the given direction (or '' at the edge). */
|
||||
function neighbour(text: string, from: number, step: -1 | 1): string {
|
||||
let i = from;
|
||||
while (i >= 0 && i < text.length && /\s/.test(text[i])) i += step;
|
||||
return i >= 0 && i < text.length ? text[i] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading/trailing commas needed to splice a new element in at `[start, end)` of a
|
||||
* JSON array: a comma before it unless the preceding element already ends in one (or
|
||||
* it is the first), and after it unless the following char closes the array (or a
|
||||
* comma is already there). Keeps the array valid whether the slot is empty, between
|
||||
* elements, or appended after one with no trailing comma.
|
||||
*/
|
||||
export function arrayAffixes(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): { lead: string; trail: string } {
|
||||
const prev = neighbour(text, start - 1, -1);
|
||||
const next = neighbour(text, end, 1);
|
||||
const lead = prev !== '' && prev !== '[' && prev !== ',' ? ', ' : '';
|
||||
const trail = next !== '' && next !== ']' && next !== ',' ? ',' : '';
|
||||
return { lead, trail };
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit that appends `entry` at the end of the JSON array spanning `array`:
|
||||
* inserted just before the closing `]`, comma-affixed so the array stays valid
|
||||
* whether it is empty or already holds elements.
|
||||
*/
|
||||
export function appendEntryEdit(text: string, array: ByteRange, entry: string): SnippetEdit {
|
||||
const offset = array.offset + array.length - 1;
|
||||
const { lead, trail } = arrayAffixes(text, offset, offset);
|
||||
return { offset, snippet: `${lead}${entry}${trail}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit that creates `"key": [content]` as the first property of the object whose
|
||||
* opening brace sits at `braceOffset`. The array is written **inline** — no interior
|
||||
* newlines for a snippet engine to re-base — indented one `tabSize` past the brace
|
||||
* *line's* leading whitespace, not the brace column, so an object a compact formatter
|
||||
* kept mid-line doesn't over-indent. A comma separates it from a following property;
|
||||
* an otherwise-empty object takes none, so the result stays valid JSON.
|
||||
*/
|
||||
export function createArrayPropertyEdit(
|
||||
text: string,
|
||||
braceOffset: number,
|
||||
tabSize: number,
|
||||
key: string,
|
||||
content: string,
|
||||
): SnippetEdit {
|
||||
const lineStart = text.lastIndexOf('\n', braceOffset - 1) + 1;
|
||||
const baseIndent = /^\s*/.exec(text.slice(lineStart, braceOffset))?.[0].length ?? 0;
|
||||
const indent = ' '.repeat(baseIndent + tabSize);
|
||||
const trail = neighbour(text, braceOffset + 1, 1) === '}' ? '' : ',';
|
||||
return { offset: braceOffset + 1, snippet: `\n${indent}"${key}": [${content}]${trail}` };
|
||||
}
|
||||
Reference in New Issue
Block a user