From e42a53572658de05a0b9eaf2b80fe73ca98b3738 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Thu, 2 Jul 2026 22:15:50 +0300 Subject: [PATCH] Editor: CodeLens + completion to scaffold Vega-Lite params; shared snippet core --- .../architecture/08-vega-editor-techniques.md | 35 ++- docs/spec/03-editor-and-drafts.md | 7 + src/app/components/SpecEditor.tsx | 11 + src/app/services/active-dataset.ts | 16 + src/app/services/editor-cursor-lens.ts | 102 +++++++ src/app/services/editor-snippet.ts | 92 ++++++ src/app/services/spec-param-scaffold.ts | 169 +++++++++++ src/app/services/spec-transform-actions.ts | 70 +---- src/app/services/spec-transform-scaffold.ts | 182 +++-------- src/core/spec-data-transforms.test.ts | 62 ++-- src/core/spec-data-transforms.ts | 39 --- src/core/spec-params.test.ts | 172 +++++++++++ src/core/spec-params.ts | 286 ++++++++++++++++++ src/core/spec-snippet.test.ts | 103 +++++++ src/core/spec-snippet.ts | 95 ++++++ 15 files changed, 1160 insertions(+), 281 deletions(-) create mode 100644 src/app/services/editor-cursor-lens.ts create mode 100644 src/app/services/editor-snippet.ts create mode 100644 src/app/services/spec-param-scaffold.ts create mode 100644 src/core/spec-params.test.ts create mode 100644 src/core/spec-params.ts create mode 100644 src/core/spec-snippet.test.ts create mode 100644 src/core/spec-snippet.ts diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index 3e39c6d..c4dba1b 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -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. diff --git a/docs/spec/03-editor-and-drafts.md b/docs/spec/03-editor-and-drafts.md index 4e9e99e..6f62ef3 100644 --- a/docs/spec/03-editor-and-drafts.md +++ b/docs/spec/03-editor-and-drafts.md @@ -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. diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index ca423da..f90e017 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -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; diff --git a/src/app/services/active-dataset.ts b/src/app/services/active-dataset.ts index 07198fa..c51e113 100644 --- a/src/app/services/active-dataset.ts +++ b/src/app/services/active-dataset.ts @@ -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 { + 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; diff --git a/src/app/services/editor-cursor-lens.ts b/src/app/services/editor-cursor-lens.ts new file mode 100644 index 0000000..e52ec55 --- /dev/null +++ b/src/app/services/editor-cursor-lens.ts @@ -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 { + /** 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( + editor: monaco.editor.IStandaloneCodeEditor, + spec: CursorLensSpec, +): 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(); + 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(); + }, + }; +} diff --git a/src/app/services/editor-snippet.ts b/src/app/services/editor-snippet.ts new file mode 100644 index 0000000..fdaac51 --- /dev/null +++ b/src/app/services/editor-snippet.ts @@ -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('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')}`, + }; +} diff --git a/src/app/services/spec-param-scaffold.ts b/src/app/services/spec-param-scaffold.ts new file mode 100644 index 0000000..3832e0a --- /dev/null +++ b/src/app/services/spec-param-scaffold.ts @@ -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(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; + }, + }); +} diff --git a/src/app/services/spec-transform-actions.ts b/src/app/services/spec-transform-actions.ts index ac5030e..c70dbe5 100644 --- a/src/app/services/spec-transform-actions.ts +++ b/src/app/services/spec-transform-actions.ts @@ -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(); - 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(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) { - const line = model.getPositionAt(target.arrayOffset).lineNumber; - lenses.push(lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0])); - } - return { lenses, dispose() {} }; + if (target.count !== 0) return []; + const line = model.getPositionAt(target.arrayOffset).lineNumber; + 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(); - }, - }; } /** diff --git a/src/app/services/spec-transform-scaffold.ts b/src/app/services/spec-transform-scaffold.ts index 541eae5..da8296c 100644 --- a/src/app/services/spec-transform-scaffold.ts +++ b/src/app/services/spec-transform-scaffold.ts @@ -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('snippetController2')?.insert(template, { - adjustWhitespace: false, - }); -} - let registered = false; /** Register the `transform[]` step-scaffold completion provider once. */ @@ -96,40 +56,25 @@ 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) => ({ - label: t.label, - kind: monaco.languages.CompletionItemKind.Snippet, - detail: t.detail, - // 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 - ? { - value: - '_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')}`, - })); + const suggestions = DATA_TRANSFORMS.map((t, i) => + scaffoldSuggestion(slot, i, { + label: t.label, + 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 + ? { + value: + '_Applies to all views below — this `transform` sits on a composition parent._', + } + : undefined, + }), + ); 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(); - 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(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 { - 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 [lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset])]; } - return { lenses, dispose() {} }; + const line = model.getPositionAt(site.array.offset).lineNumber; + 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(); - }, - }; } diff --git a/src/core/spec-data-transforms.test.ts b/src/core/spec-data-transforms.test.ts index 27c0d15..4201174 100644 --- a/src/core/spec-data-transforms.test.ts +++ b/src/core/spec-data-transforms.test.ts @@ -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}`); diff --git a/src/core/spec-data-transforms.ts b/src/core/spec-data-transforms.ts index 62a8e6e..dc5c0aa 100644 --- a/src/core/spec-data-transforms.ts +++ b/src/core/spec-data-transforms.ts @@ -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 }; -} diff --git a/src/core/spec-params.test.ts b/src/core/spec-params.test.ts new file mode 100644 index 0000000..ae5f0f1 --- /dev/null +++ b/src/core/spec-params.test.ts @@ -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'); + }); +}); diff --git a/src/core/spec-params.ts b/src/core/spec-params.ts new file mode 100644 index 0000000..3460bf3 --- /dev/null +++ b/src/core/spec-params.ts @@ -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", ]`) + * 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}"] } }`, + }, +]; diff --git a/src/core/spec-snippet.test.ts b/src/core/spec-snippet.test.ts new file mode 100644 index 0000000..9addbae --- /dev/null +++ b/src/core/spec-snippet.test.ts @@ -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); + }); +}); diff --git a/src/core/spec-snippet.ts b/src/core/spec-snippet.ts new file mode 100644 index 0000000..aa6adf1 --- /dev/null +++ b/src/core/spec-snippet.ts @@ -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}` }; +}