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
+11
View File
@@ -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;
+16
View File
@@ -28,6 +28,7 @@ import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
import { derivedFieldNamesAtPath } from '@core/spec-fields';
import { rowsForDataBinding } from '@core/spec-inline-data';
import type { ParamField } from '@core/spec-params';
import type { ColumnType } from '@core/type-inference';
import { useDatasetStore } from '../stores/DatasetStore';
@@ -183,6 +184,21 @@ export function boundColumnsAt(
return sourceAt(text, offset).columnTypes;
}
/**
* Source columns with their numeric extent in scope at `offset` (for parameter
* scaffolding — a slider's bounds come from the field's `numericExtent`). Zips the
* profile's type and stat arrays by name; a non-numeric column carries a null extent.
*/
export function boundParamFieldsAt(text: string, offset: number): ReadonlyArray<ParamField> {
const source = sourceAt(text, offset);
const extentByName = new Map(source.columnStats.map((s) => [s.name, s.numericExtent]));
return source.columnTypes.map((c) => ({
name: c.name,
type: c.type,
extent: extentByName.get(c.name) ?? null,
}));
}
/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
+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 { findViewRange } from '@core/spec-cursor';
import {
type CompositionTarget,
compositionTargetAt,
elementOffset,
insertView,
@@ -52,6 +53,7 @@ import {
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumnsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */
@@ -475,36 +477,18 @@ export function installSpecTransformCodeLens(
runMoveView(editor, path, index, delta),
);
const lens = (
line: number,
title: string,
id: string | null,
args: unknown[],
): monaco.languages.CodeLens => ({
range: new monaco.Range(line, 1, line, 1),
command: { id: id ?? '', title, arguments: args },
});
// The lenses depend on the cursor, so signal a refresh when the enclosing view
// changes — keyed so typing within one view does not re-render them.
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
const codeLensProvider: monaco.languages.CodeLensProvider = {
onDidChange: onDidChange.event,
provideCodeLenses(model) {
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
const pos = editor.getPosition();
if (!pos) return { lenses: [], dispose() {} };
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
if (!target) return { lenses: [], dispose() {} };
const lenses: monaco.languages.CodeLens[] = [];
return installCursorLens<CompositionTarget>(editor, {
resolve: compositionTargetAt,
// Refresh when the enclosing view or the sibling count changes, not on every keystroke.
keyOf: (target) =>
JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count]),
lensesOf: (target, model, lens) => {
if (!target.element) {
// Off a view, only an empty composition gets an affordance — the array's
// own line stays clean otherwise.
if (target.count === 0) {
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();
},
};
}
/**
+37 -145
View File
@@ -25,60 +25,20 @@
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import {
arrayAffixes,
DATA_TRANSFORMS,
neighbour,
transformPlacementAt,
transformSiteAt,
type TransformSite,
} from '@core/spec-data-transforms';
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
import { boundColumnsAt } from './active-dataset';
import { installCursorLens } from './editor-cursor-lens';
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
import { useSnippetStore } from '../stores/SnippetStore';
/** The transforms surfaced as CodeLens buttons — the common few; the completion has the rest. */
const COMMON_STEP_IDS = ['filter', 'aggregate', 'calculate', 'bin', 'timeUnit'] as const;
// TODO: the cursor-aware CodeLens skeleton (the `lens()` factory + `onDidChange` emitter +
// cursor-keyed refresh + dispose) is ALREADY duplicated between this installer and
// `spec-transform-actions.installSpecTransformCodeLens` — two shipped sites. A shared
// `installCursorLens(editor, resolve, keyOf, lensesOf)` (~-55 LOC) is earned; deferred so it
// lands as its own change with both lenses live-verified together, not folded into this
// feature commit and its still-unexercised click path.
//
// TODO: `params` scaffolding is the natural sibling ( Add parameter → slider / dropdown /
// radio / point / interval). It reuses the CodeLens shape but NOT the transform internals:
// `params` isn't on every view node the way `transform` is (variable params are top-level;
// selection params are unit-only), so it needs its own site resolver, not `transformSiteAt`;
// its smart defaults come from column stats (slider min/max ← numericExtent, dropdown options
// ← distinct values) and the view's encodings, not the step catalog. Build as a separate
// reviewed change — that third consumer is the right moment to shape the shared skeleton above.
/** Monaco's snippet contribution — the public entry to insert a `${n:…}` template with tab stops. */
interface SnippetInserter extends monaco.editor.IEditorContribution {
insert(template: string, opts?: { adjustWhitespace?: boolean }): void;
}
/**
* Insert a snippet template at `offset` through Monaco's snippet engine, so its
* `${n:default}` tab stops become a live, Tab-through session. Places the cursor
* first (the controller inserts at the selection). `adjustWhitespace: false` keeps
* the engine from re-basing our explicit indentation to the insertion line's — it
* otherwise leaves a multi-line block's closing bracket under-indented. No-op if the
* contribution is absent (it ships in `edcore.main`, so this is just defensive).
*/
function insertSnippetAt(
editor: monaco.editor.IStandaloneCodeEditor,
offset: number,
template: string,
): void {
const model = editor.getModel();
if (!model) return;
editor.setPosition(model.getPositionAt(offset));
editor.focus();
editor.getContribution<SnippetInserter>('snippetController2')?.insert(template, {
adjustWhitespace: false,
});
}
let registered = false;
/** Register the `transform[]` step-scaffold completion provider once. */
@@ -96,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<monaco.languages.CodeLensProvider>();
const provider: monaco.languages.CodeLensProvider = {
onDidChange: onDidChange.event,
provideCodeLenses(model) {
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
const pos = editor.getPosition();
if (!pos) return { lenses: [], dispose() {} };
const site = transformSiteAt(model.getValue(), model.getOffsetAt(pos));
if (!site) return { lenses: [], dispose() {} };
const lenses: monaco.languages.CodeLens[] = [];
return installCursorLens<TransformSite>(editor, {
resolve: transformSiteAt,
// Refresh when the enclosing view or its step count changes, not on every keystroke.
keyOf: (site) => JSON.stringify([site.view.offset, site.array?.count ?? -1]),
lensesOf: (site, model, lens) => {
if (!site.array) {
const line = model.getPositionAt(site.view.offset).lineNumber;
lenses.push(lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset]));
} else {
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();
},
};
}
+21 -41
View File
@@ -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}`);
-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}"] } }`,
},
];
/**
* 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}` };
}