Editor: CodeLens + completion to scaffold Vega-Lite params; shared snippet core

This commit is contained in:
2026-07-02 22:15:50 +03:00
parent c5e4c4c76d
commit e42a535726
15 changed files with 1160 additions and 281 deletions
+34 -1
View File
@@ -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 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 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. 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 - `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 `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). 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 **Services (app, store-aware via `getState`):** `spec-transform-actions` (the
wrap/simplify/add-view operations and their surfaces), `spec-transform-scaffold` (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 inlay providers), `spec-expression-hints` (expression completion, signature help, hover, and
the diagnostic markers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats 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. 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 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 need the editor handle — the `addAction` context/F1 commands, and the CodeLens whose command
runs `executeEdits` — are installed **per editor** and disposed with it. 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 - **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 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 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 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 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. 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 - **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: `CodeAction` carries no icon field. Custom iconography lives only where it is supported:
CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations. CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations.
+7
View File
@@ -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'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. - 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 ## 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. Edits persist automatically so the user never loses work and never needs an explicit "save" action for ordinary editing.
+11
View File
@@ -54,6 +54,10 @@ import {
configureSpecTransformScaffold, configureSpecTransformScaffold,
installSpecTransformScaffoldCodeLens, installSpecTransformScaffoldCodeLens,
} from '../services/spec-transform-scaffold'; } from '../services/spec-transform-scaffold';
import {
configureSpecParamScaffold,
installSpecParamScaffoldCodeLens,
} from '../services/spec-param-scaffold';
import { runExtract } from '../services/extract-action'; import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
@@ -179,6 +183,8 @@ configureSpecDatasetHints();
configureSpecExpressionHints(); configureSpecExpressionHints();
// Register the data-transform step-scaffold completion once (docs/architecture/08). // Register the data-transform step-scaffold completion once (docs/architecture/08).
configureSpecTransformScaffold(); configureSpecTransformScaffold();
// Register the parameter-scaffold completion once (docs/architecture/08).
configureSpecParamScaffold();
/** The two spec↔config operations, surfaced as an overflow menu (council: /** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space * 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. // — per editor for the same reason: its commands drive this editor's snippet edit.
const scaffoldLensSub = installSpecTransformScaffoldCodeLens(editor); 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 // Validate Vega expressions in the draft and squiggle the invalid ones — per
// editor, because it writes markers to this model (docs/architecture/08). // editor, because it writes markers to this model (docs/architecture/08).
// Debounced internally; recomputes on edit and on a draft↔published toggle. // Debounced internally; recomputes on edit and on a draft↔published toggle.
@@ -483,6 +493,7 @@ export function SpecEditor() {
transformActionsSub.dispose(); transformActionsSub.dispose();
codeLensSub.dispose(); codeLensSub.dispose();
scaffoldLensSub.dispose(); scaffoldLensSub.dispose();
paramScaffoldLensSub.dispose();
exprMarkersSub.dispose(); exprMarkersSub.dispose();
editor.dispose(); editor.dispose();
editorRef.current = null; editorRef.current = null;
+16
View File
@@ -28,6 +28,7 @@ import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data'; import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
import { derivedFieldNamesAtPath } from '@core/spec-fields'; import { derivedFieldNamesAtPath } from '@core/spec-fields';
import { rowsForDataBinding } from '@core/spec-inline-data'; import { rowsForDataBinding } from '@core/spec-inline-data';
import type { ParamField } from '@core/spec-params';
import type { ColumnType } from '@core/type-inference'; import type { ColumnType } from '@core/type-inference';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
@@ -183,6 +184,21 @@ export function boundColumnsAt(
return sourceAt(text, offset).columnTypes; 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). */ /** 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 { export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null; return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
+102
View File
@@ -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();
},
};
}
+92
View File
@@ -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')}`,
};
}
+169
View File
@@ -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;
},
});
}
+15 -55
View File
@@ -34,6 +34,7 @@ import { formatJson } from '@core/json-format';
import { isJsonObject, type JsonObject } from '@core/spec-config'; import { isJsonObject, type JsonObject } from '@core/spec-config';
import { findViewRange } from '@core/spec-cursor'; import { findViewRange } from '@core/spec-cursor';
import { import {
type CompositionTarget,
compositionTargetAt, compositionTargetAt,
elementOffset, elementOffset,
insertView, insertView,
@@ -52,6 +53,7 @@ import {
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumnsAt } from './active-dataset'; import { boundColumnsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { parseSpecObject } from './spec-config-actions'; import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */ /** The composition operators the wrap actions offer. */
@@ -475,36 +477,18 @@ export function installSpecTransformCodeLens(
runMoveView(editor, path, index, delta), runMoveView(editor, path, index, delta),
); );
const lens = ( return installCursorLens<CompositionTarget>(editor, {
line: number, resolve: compositionTargetAt,
title: string, // Refresh when the enclosing view or the sibling count changes, not on every keystroke.
id: string | null, keyOf: (target) =>
args: unknown[], JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count]),
): monaco.languages.CodeLens => ({ lensesOf: (target, model, lens) => {
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[] = [];
if (!target.element) { if (!target.element) {
// Off a view, only an empty composition gets an affordance — the array's // Off a view, only an empty composition gets an affordance — the array's
// own line stays clean otherwise. // own line stays clean otherwise.
if (target.count === 0) { if (target.count !== 0) return [];
const line = model.getPositionAt(target.arrayOffset).lineNumber; const line = model.getPositionAt(target.arrayOffset).lineNumber;
lenses.push(lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0])); return [lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0])];
}
return { lenses, dispose() {} };
} }
const { index, offset, length } = target.element; const { index, offset, length } = target.element;
// "above" actions sit on the view's first line, "below" on the line after // "above" actions sit on the view's first line, "below" on the line after
@@ -514,7 +498,9 @@ export function installSpecTransformCodeLens(
model.getLineCount(), model.getLineCount(),
model.getPositionAt(offset + length).lineNumber + 1, 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) if (index > 0)
lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1])); 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])); lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
@@ -522,35 +508,9 @@ export function installSpecTransformCodeLens(
lenses.push( lenses.push(
lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]), 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();
},
};
} }
/** /**
+37 -145
View File
@@ -25,60 +25,20 @@
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { import {
arrayAffixes,
DATA_TRANSFORMS, DATA_TRANSFORMS,
neighbour,
transformPlacementAt, transformPlacementAt,
transformSiteAt, transformSiteAt,
type TransformSite,
} from '@core/spec-data-transforms'; } from '@core/spec-data-transforms';
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
import { boundColumnsAt } from './active-dataset'; import { boundColumnsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
/** The transforms surfaced as CodeLens buttons — the common few; the completion has the rest. */ /** 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; 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; let registered = false;
/** Register the `transform[]` step-scaffold completion provider once. */ /** Register the `transform[]` step-scaffold completion provider once. */
@@ -96,40 +56,25 @@ export function configureSpecTransformScaffold(): void {
const placement = transformPlacementAt(text, offset); const placement = transformPlacementAt(text, offset);
if (placement.kind !== 'step') return { suggestions: [] }; if (placement.kind !== 'step') return { suggestions: [] };
// Replace the bare partial the user is typing as the element (e.g. `fil`), const slot = scaffoldSlotAt(model, position, text, offset);
// 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 fields = boundColumnsAt(text, offset); const fields = boundColumnsAt(text, offset);
const shared = placement.scope === 'shared'; const shared = placement.scope === 'shared';
const suggestions = DATA_TRANSFORMS.map((t, i) => ({ const suggestions = DATA_TRANSFORMS.map((t, i) =>
label: t.label, scaffoldSuggestion(slot, i, {
kind: monaco.languages.CompletionItemKind.Snippet, label: t.label,
detail: t.detail, detail: t.detail,
// A shared step (on a composition parent) transforms the data feeding every body: t.step(fields),
// child view, not just one — surface that so the placement isn't a surprise. // A shared step (on a composition parent) transforms the data feeding every
documentation: shared // 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._', value:
} '_Applies to all views below — this `transform` sits on a composition parent._',
: undefined, }
insertText: `${lead}${t.step(fields)}${trail}`, : undefined,
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 }; return { suggestions };
}, },
}); });
@@ -152,19 +97,16 @@ function runAddStep(
const transform = DATA_TRANSFORMS.find((t) => t.id === id); const transform = DATA_TRANSFORMS.find((t) => t.id === id);
if (!site?.array || !transform) return; if (!site?.array || !transform) return;
const insertOffset = site.array.offset + site.array.length - 1; // just before the ']' const fields = boundColumnsAt(text, site.array.offset);
const { lead, trail } = arrayAffixes(text, insertOffset, insertOffset); const edit = appendEntryEdit(text, site.array, transform.step(fields));
const fields = boundColumnsAt(text, insertOffset); insertSnippetAt(editor, edit.offset, edit.snippet);
insertSnippetAt(editor, insertOffset, `${lead}${transform.step(fields)}${trail}`);
} }
/** /**
* Insert an empty `transform: []` as the view's first property and drop the cursor * 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 * 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 * fills the array in place. Placement, indentation, and the comma are
* the snippet engine to re-base — so it lands cleanly indented off the view line's own * `createArrayPropertyEdit`'s tested math.
* 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.
*/ */
function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset: number): void { function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset: number): void {
const model = editor.getModel(); const model = editor.getModel();
@@ -173,15 +115,9 @@ function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset
const site = transformSiteAt(text, viewOffset + 1); const site = transformSiteAt(text, viewOffset + 1);
if (!site || site.array) return; // gone, or already has a pipeline 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; const tab = model.getOptions().tabSize || 2;
// Indent off the brace *line's* leading whitespace, not the brace column — a view const edit = createArrayPropertyEdit(text, site.view.offset, tab, 'transform', '$0');
// the compact formatter kept inline (mid-line '{') would otherwise over-indent. insertSnippetAt(editor, edit.offset, edit.snippet);
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}`);
} }
/** /**
@@ -201,63 +137,19 @@ export function installSpecTransformScaffoldCodeLens(
runAddStep(editor, id, arrayOffset), runAddStep(editor, id, arrayOffset),
); );
const lens = ( return installCursorLens<TransformSite>(editor, {
line: number, resolve: transformSiteAt,
title: string, // Refresh when the enclosing view or its step count changes, not on every keystroke.
id: string | null, keyOf: (site) => JSON.stringify([site.view.offset, site.array?.count ?? -1]),
args: unknown[], lensesOf: (site, model, lens) => {
): 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[] = [];
if (!site.array) { if (!site.array) {
const line = model.getPositionAt(site.view.offset).lineNumber; const line = model.getPositionAt(site.view.offset).lineNumber;
lenses.push(lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset])); return [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 { 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();
},
};
} }
+21 -41
View File
@@ -1,12 +1,19 @@
import Ajv, { type ValidateFunction } from 'ajv';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
import { import {
arrayAffixes,
DATA_TRANSFORMS, DATA_TRANSFORMS,
snippetToPlain,
transformPlacementAt, transformPlacementAt,
transformSiteAt, transformSiteAt,
type TypedField, type TypedField,
} from './spec-data-transforms'; } 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. */ /** Place a `|` marker in the source, strip it, and classify at that offset. */
function placeAt(marked: string) { 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) { for (const t of DATA_TRANSFORMS) {
it(`${t.id} (with fields in scope)`, () => { it(`${t.id} (with fields in scope)`, () => {
expect(() => { const ok = validateSpec(withStep(t.step(FIELDS)));
JSON.parse(snippetToPlain(t.step(FIELDS))); expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
}).not.toThrow();
}); });
it(`${t.id} (no data in scope — generic fallbacks)`, () => { it(`${t.id} (no data in scope — generic fallbacks)`, () => {
expect(() => { const ok = validateSpec(withStep(t.step([])));
JSON.parse(snippetToPlain(t.step([]))); expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
}).not.toThrow();
}); });
} }
}); });
@@ -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) { function byId(id: string) {
const t = DATA_TRANSFORMS.find((x) => x.id === id); const t = DATA_TRANSFORMS.find((x) => x.id === id);
if (!t) throw new Error(`no transform ${id}`); if (!t) throw new Error(`no transform ${id}`);
-39
View File
@@ -282,42 +282,3 @@ export const DATA_TRANSFORMS: readonly DataTransform[] = [
`{ "lookup": "\${1:key}", "from": { "data": { "name": "\${2:dataset}" }, "key": "\${3:key}", "fields": ["\${4:field}"] } }`, `{ "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 };
}
+172
View File
@@ -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 0100 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');
});
});
+286
View File
@@ -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 ~50100 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 0100 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}"] } }`,
},
];
+103
View File
@@ -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);
});
});
+95
View File
@@ -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}` };
}