diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md
index 81a3f07..3e39c6d 100644
--- a/docs/architecture/08-vega-editor-techniques.md
+++ b/docs/architecture/08-vega-editor-techniques.md
@@ -285,7 +285,15 @@ logic is pure `src/core/`; the Monaco glue is thin app-layer services.
**Core (pure, portable):**
- `spec-transforms` — wrap a view in `layer`/`hconcat`/`vconcat`/`facet`/`repeat`; collapse
- a single-child composition (`unwrapSingleton`). Object-in/object-out.
+ a single-child composition (`unwrapSingleton`). Object-in/object-out. (Surfaced as the
+ toolbar **Compose** menu — named to read distinctly from a data `transform`, below.)
+- `spec-data-transforms` — the data-pipeline counterpart: `transformSiteAt` resolves the view
+ the cursor is in and its `transform[]` range/count (for the CodeLens), `transformPlacementAt`
+ classifies a step slot (for the completion) — both tagged `shared` when the pipeline sits on a
+ composition parent, `view` on a unit. `DATA_TRANSFORMS` is the field-typed step catalog (filter,
+ calculate, aggregate, bin, timeUnit, window, joinaggregate, fold, lookup); each builder takes the
+ columns in scope and seeds each `${n:default}` tab stop with a type-appropriate one. A test
+ round-trips every snippet through `snippetToPlain` to assert it's valid JSON.
- `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring
`isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope
at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent).
@@ -335,11 +343,11 @@ logic is pure `src/core/`; the Monaco glue is thin app-layer services.
curated parameter signatures for the commonly-typed functions (what the registry can't supply).
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
-wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (data-column
-completion, hover, inlay providers), `spec-expression-hints` (expression completion,
-signature help, hover, and the diagnostic markers), `active-dataset` (`dataInfoAt(text,
-offset)` — the columns/types/stats plus derived fields the draft sees at the cursor).
-`SpecEditor` does the wiring.
+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,
+inlay providers), `spec-expression-hints` (expression completion, signature help, hover, and
+the diagnostic markers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
+plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring.
Decision rules:
@@ -407,7 +415,25 @@ Decision rules:
`datum.` or `fn(` spans the whole `datum.`/`fn(` token; used as a completion item's range it both
mis-targets the edit and filters every suggestion out (none start with `datum.`). A provider
completing inside a string must build the replace range from the partial it parses itself — a rule
- any future in-string completion (transform/param scaffolding) inherits.
+ the transform scaffold inherits (it parses the trailing element word off the line). This
+ `new Range(line, col − partialLen, line, col)` construction is now at three sites
+ (`spec-expression-hints`, `spec-dataset-hints`, `spec-transform-scaffold`); a shared
+ `replaceRange(position, partialLength)` helper is earned and should be extracted on the next touch.
+- **Data-transform scaffolding is a CodeLens (discoverable) plus a completion (accelerator), on
+ the one home the schema leaves bare.** A Vega-Lite data `transform` has three possible homes,
+ confirmed against the bundled schema (the `transform` array is on 16 spec types, i.e. every view
+ node): a `transform[]` **step**; the **inline** field props on an encoding channel
+ (`bin`/`timeUnit`/`aggregate`/`sort`); and a **new** pipeline on a bare view. We scaffold the step
+ home only, on the same "add only what the schema lacks" rule as `spec-dataset-hints`: the schema
+ already completes the inline channel keys and their enum values, and the `transform` key itself —
+ but never a ready, field-typed `{ "filter": … }`. The **CodeLens is the discoverable surface**
+ (a completion is invisible until provoked and competes silently with the schema's suggest items):
+ cursor-scoped like the composition CodeLens, it shows `+ Add transform` on a view with no pipeline
+ and per-step `+ filter`/`+ aggregate`/… on the array, each clicking through Monaco's snippet
+ engine so the field-typed tab stops survive. The completion is the type-to-filter accelerator on
+ the same catalog. A step's `scope` (`shared` when the array is on a composition parent, so it
+ feeds every child) is surfaced so the placement is not a surprise, and comma affixing keeps the
+ array valid whether the slot is empty, between elements, or appended after one without a trailing comma.
- **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's
`CodeAction` carries no icon field. Custom iconography lives only where it is supported:
CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations.
diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx
index 51d5205..ca423da 100644
--- a/src/app/components/SpecEditor.tsx
+++ b/src/app/components/SpecEditor.tsx
@@ -50,6 +50,10 @@ import {
configureSpecExpressionHints,
installExpressionMarkers,
} from '../services/spec-expression-hints';
+import {
+ configureSpecTransformScaffold,
+ installSpecTransformScaffoldCodeLens,
+} from '../services/spec-transform-scaffold';
import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
@@ -173,6 +177,8 @@ configureSpecTransformCodeActions();
configureSpecDatasetHints();
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
configureSpecExpressionHints();
+// Register the data-transform step-scaffold completion once (docs/architecture/08).
+configureSpecTransformScaffold();
/** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space
@@ -198,11 +204,12 @@ const CONFIG_ACTIONS = [
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
-/** Structural transforms, surfaced as a sibling menu to Config (the discoverable
+/** Composition restructures, surfaced as a sibling menu to Config (the discoverable
* home; the lightbulb and F1 palette are the accelerators — see
* services/spec-transform-actions). They act on the selection, else the whole
- * spec. */
-const TRANSFORM_ACTIONS = [
+ * spec. Named "Compose" so it reads distinctly from a Vega-Lite data `transform`
+ * (the pipeline scaffolded by the editor completion — services/spec-transform-scaffold). */
+const COMPOSE_ACTIONS = [
{ value: 'layer', label: 'Wrap in layer', detail: 'Overlay marks on shared scales' },
{ value: 'hconcat', label: 'Wrap in horizontal concat', detail: 'Place views side by side' },
{ value: 'vconcat', label: 'Wrap in vertical concat', detail: 'Stack views top to bottom' },
@@ -215,7 +222,7 @@ const TRANSFORM_ACTIONS = [
},
] as const;
-type TransformActionId = (typeof TRANSFORM_ACTIONS)[number]['value'];
+type ComposeActionId = (typeof COMPOSE_ACTIONS)[number]['value'];
function EditorToolbar({
editorRef,
@@ -280,7 +287,7 @@ function EditorToolbar({
else runExtractConfigToTheme(editor);
};
- const handleTransformAction = (action: TransformActionId) => {
+ const handleComposeAction = (action: ComposeActionId) => {
const editor = editorRef.current;
if (!editor) return;
if (action === 'simplify') runUnwrap(editor);
@@ -344,13 +351,13 @@ function EditorToolbar({
)}
('snippetController2')?.insert(template);
+}
+
+let registered = false;
+
+/** Register the `transform[]` step-scaffold completion provider once. */
+export function configureSpecTransformScaffold(): 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 = 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 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')}`,
+ }));
+ return { suggestions };
+ },
+ });
+}
+
+/**
+ * Splice a field-typed step into the `transform` array at `arrayOffset`, via Monaco's
+ * snippet engine so the placeholders are Tab-through. Re-resolves the array from the
+ * live text (the lens args may be a version stale) and skips silently if it's gone.
+ */
+function runAddStep(
+ editor: monaco.editor.IStandaloneCodeEditor,
+ id: string,
+ arrayOffset: number,
+): void {
+ const model = editor.getModel();
+ if (!model) return;
+ const text = model.getValue();
+ const site = transformSiteAt(text, arrayOffset + 1);
+ 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}`);
+}
+
+/**
+ * Insert an empty `transform: []` as the view's first property and drop the cursor
+ * inside it (a `$0` tab stop), so the per-step lenses appear next. Indented off the
+ * view line's own nesting, and separated from the next property with a comma only
+ * when one follows — a view with no other key (a bare `{}`) takes none, so the result
+ * stays valid JSON. The snippet engine precludes a reformat, so the indent is a best
+ * effort the next document format tidies.
+ */
+function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset: number): void {
+ const model = editor.getModel();
+ if (!model) return;
+ const text = model.getValue();
+ 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 inner = ' '.repeat(tab);
+ const trail = neighbour(text, brace + 1, 1) === '}' ? '' : ',';
+ insertSnippetAt(
+ editor,
+ brace + 1,
+ `\n${indent}"transform": [\n${indent}${inner}$0\n${indent}]${trail}`,
+ );
+}
+
+/**
+ * Install the cursor-aware transform-scaffold CodeLens (per editor — its commands
+ * need this editor's handle to apply the edit, exactly like the composition CodeLens
+ * in `spec-transform-actions`). Over the view the cursor sits in it shows `+ Add
+ * transform` when there is no pipeline, or `+ filter`/`+ aggregate`/… on the array
+ * when there is. Returns a disposable; dispose on unmount.
+ */
+export function installSpecTransformScaffoldCodeLens(
+ editor: monaco.editor.IStandaloneCodeEditor,
+): monaco.IDisposable {
+ const addTransformCmd = editor.addCommand(0, (_a, viewOffset: number) =>
+ runAddTransform(editor, viewOffset),
+ );
+ const addStepCmd = editor.addCommand(0, (_a, id: string, arrayOffset: number) =>
+ runAddStep(editor, id, arrayOffset),
+ );
+
+ const lens = (
+ line: number,
+ title: string,
+ id: string | null,
+ args: unknown[],
+ ): monaco.languages.CodeLens => ({
+ range: new monaco.Range(line, 1, line, 1),
+ command: { id: id ?? '', title, arguments: args },
+ });
+
+ // The lenses follow the cursor's view, so signal a refresh when that changes —
+ // keyed to the view and its step count so typing within one view doesn't churn them.
+ const onDidChange = new monaco.Emitter();
+ const provider: monaco.languages.CodeLensProvider = {
+ onDidChange: onDidChange.event,
+ provideCodeLenses(model) {
+ if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
+ const pos = editor.getPosition();
+ if (!pos) return { lenses: [], dispose() {} };
+ const site = transformSiteAt(model.getValue(), model.getOffsetAt(pos));
+ if (!site) return { lenses: [], dispose() {} };
+
+ const lenses: monaco.languages.CodeLens[] = [];
+ 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 { lenses, dispose() {} };
+ },
+ };
+ const registration = monaco.languages.registerCodeLensProvider('json', provider);
+
+ let lastKey = '';
+ const cursorSub = editor.onDidChangeCursorPosition(() => {
+ const model = editor.getModel();
+ const pos = editor.getPosition();
+ const site =
+ model && pos && useSnippetStore.getState().editorView === 'draft'
+ ? transformSiteAt(model.getValue(), model.getOffsetAt(pos))
+ : null;
+ const key = site ? JSON.stringify([site.view.offset, site.array?.count ?? -1]) : '';
+ if (key !== lastKey) {
+ lastKey = key;
+ onDidChange.fire(provider);
+ }
+ });
+
+ return {
+ dispose() {
+ cursorSub.dispose();
+ onDidChange.dispose();
+ registration.dispose();
+ },
+ };
+}
diff --git a/src/core/spec-data-transforms.test.ts b/src/core/spec-data-transforms.test.ts
new file mode 100644
index 0000000..27c0d15
--- /dev/null
+++ b/src/core/spec-data-transforms.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it } from 'vitest';
+import {
+ arrayAffixes,
+ DATA_TRANSFORMS,
+ snippetToPlain,
+ transformPlacementAt,
+ transformSiteAt,
+ type TypedField,
+} from './spec-data-transforms';
+
+/** Place a `|` marker in the source, strip it, and classify at that offset. */
+function placeAt(marked: string) {
+ const offset = marked.indexOf('|');
+ return transformPlacementAt(marked.replace('|', ''), offset);
+}
+
+/** Place a `|` marker in the source, strip it, and resolve the transform site. */
+function siteAt(marked: string) {
+ const offset = marked.indexOf('|');
+ return transformSiteAt(marked.replace('|', ''), offset);
+}
+
+const FIELDS: TypedField[] = [
+ { name: 'price', type: 'number' },
+ { name: 'date', type: 'date' },
+ { name: 'origin', type: 'string' },
+];
+
+describe('transformPlacementAt — step slots', () => {
+ it('classifies an empty transform array as a step slot', () => {
+ expect(placeAt('{"transform":[|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
+ });
+
+ it('classifies the slot after an existing step', () => {
+ expect(placeAt('{"transform":[{"filter":"x"},|],"mark":"bar"}')).toEqual({
+ kind: 'step',
+ scope: 'view',
+ });
+ });
+
+ it('classifies a bare partial word in the array as a step slot', () => {
+ // `fil` is not valid JSON, but jsonc-parser locates the slot regardless.
+ expect(placeAt('{"transform":[fil|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
+ });
+
+ it('marks a transform on a composition parent as shared (upstream of children)', () => {
+ const src = '{"transform":[|],"layer":[{"mark":"bar"},{"mark":"line"}]}';
+ expect(placeAt(src)).toEqual({ kind: 'step', scope: 'shared' });
+ });
+
+ it('resolves the owning view of a nested transform (layer child = local)', () => {
+ const src = '{"layer":[{"transform":[|],"mark":"bar"},{"mark":"line"}]}';
+ expect(placeAt(src)).toEqual({ kind: 'step', scope: 'view' });
+ });
+});
+
+describe('transformPlacementAt — not a step slot', () => {
+ it('stays out of a key inside an existing step object (the schema owns that)', () => {
+ expect(placeAt('{"transform":[{"fil|"}],"mark":"bar"}')).toEqual({ kind: 'none' });
+ });
+
+ it('does not fire at an encoding channel key (the schema completes those)', () => {
+ expect(placeAt('{"encoding":{"x":{"field":"date","ti|"}}}')).toEqual({ kind: 'none' });
+ });
+
+ it('does not fire at a bare view key', () => {
+ expect(placeAt('{"mark":"bar","tr|"}')).toEqual({ kind: 'none' });
+ });
+
+ it('returns none for a plain value position', () => {
+ expect(placeAt('{"mark":"|"}')).toEqual({ kind: 'none' });
+ });
+});
+
+describe('transformSiteAt', () => {
+ it('reports no array on a bare unit view (cursor anywhere inside it)', () => {
+ const site = siteAt('{"mark":"bar","encoding":{"x":{"field":"a|"}}}');
+ expect(site).toMatchObject({ scope: 'view', array: null });
+ expect(site?.view.offset).toBe(0);
+ });
+
+ it('reports the array and its element count when a pipeline exists', () => {
+ const site = siteAt('{"transform":[{"filter":"x"}],"mark":"|bar"}');
+ expect(site?.array).toMatchObject({ count: 1 });
+ });
+
+ it('reports an empty array with count 0', () => {
+ const site = siteAt('{"transform":[|],"mark":"bar"}');
+ expect(site?.array).toMatchObject({ count: 0 });
+ });
+
+ it('resolves the site from inside the transform array itself', () => {
+ const site = siteAt('{"transform":[{"filter":"x"},|],"mark":"bar"}');
+ expect(site?.array).toMatchObject({ count: 1 });
+ expect(site?.view.offset).toBe(0);
+ });
+
+ it('climbs to the nearest view — a layer child is local (view scope)', () => {
+ const site = siteAt('{"layer":[{"mark":"ba|r"},{"mark":"line"}]}');
+ expect(site).toMatchObject({ scope: 'view', array: null });
+ expect(site?.view.offset).toBeGreaterThan(0); // the child, not the root
+ });
+
+ it('marks a composition parent’s pipeline as shared', () => {
+ const site = siteAt('{"transform":[|],"layer":[{"mark":"bar"}]}');
+ expect(site).toMatchObject({ scope: 'shared' });
+ });
+
+ it('the view range spans the whole enclosing view object', () => {
+ const src = '{"mark":"bar","encodi|ng":{}}';
+ const site = siteAt(src);
+ // offset at the opening brace, length to the closing brace inclusive.
+ expect(site?.view.offset).toBe(0);
+ expect(site?.view.length).toBe(src.replace('|', '').length);
+ });
+});
+
+describe('catalog — every step snippet is valid JSON once tab stops are stripped', () => {
+ for (const t of DATA_TRANSFORMS) {
+ it(`${t.id} (with fields in scope)`, () => {
+ expect(() => {
+ JSON.parse(snippetToPlain(t.step(FIELDS)));
+ }).not.toThrow();
+ });
+ it(`${t.id} (no data in scope — generic fallbacks)`, () => {
+ expect(() => {
+ JSON.parse(snippetToPlain(t.step([])));
+ }).not.toThrow();
+ });
+ }
+});
+
+describe('catalog — field-typed defaults', () => {
+ it('seeds filter with the first available column', () => {
+ expect(JSON.parse(snippetToPlain(byId('filter').step(FIELDS)))).toEqual({
+ filter: 'datum.price > 0',
+ });
+ });
+
+ it('seeds aggregate with a numeric field and a categorical groupby', () => {
+ const parsed = parseAggregate(byId('aggregate').step(FIELDS));
+ expect(parsed.aggregate[0]).toMatchObject({ field: 'price', as: 'price_mean' });
+ expect(parsed.groupby).toEqual(['origin']);
+ });
+
+ it('seeds timeUnit with a temporal field', () => {
+ const parsed = JSON.parse(snippetToPlain(byId('timeUnit').step(FIELDS))) as { field: string };
+ expect(parsed.field).toBe('date');
+ });
+
+ it('falls back to generic names when no column matches the need', () => {
+ const parsed = parseAggregate(byId('aggregate').step([]));
+ expect(parsed.aggregate[0].field).toBe('value');
+ expect(parsed.groupby).toEqual(['category']);
+ });
+});
+
+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}`);
+ return t;
+}
+
+/** Parse an aggregate step snippet into its (typed) fields, for assertions. */
+function parseAggregate(snippet: string): {
+ aggregate: Array<{ op: string; field: string; as: string }>;
+ groupby: string[];
+} {
+ return JSON.parse(snippetToPlain(snippet)) as {
+ aggregate: Array<{ op: string; field: string; as: string }>;
+ groupby: string[];
+ };
+}
diff --git a/src/core/spec-data-transforms.ts b/src/core/spec-data-transforms.ts
new file mode 100644
index 0000000..62a8e6e
--- /dev/null
+++ b/src/core/spec-data-transforms.ts
@@ -0,0 +1,323 @@
+/**
+ * Data-transform scaffolding (docs/architecture/08 → editor augmentation) — the
+ * pure counterpart of `spec-transforms`. Where `spec-transforms` rewrites a view's
+ * *structure* (wrap in a composition), this scaffolds a view's *data pipeline*: the
+ * Vega-Lite `transform` steps (filter, calculate, aggregate, …) the editor offers as
+ * ready-to-fill, field-typed placeholders. Text/Monaco handling (ranges, insertion,
+ * the CodeLens) is the service's job (app/services/spec-transform-scaffold); this
+ * owns the facts the schema can't supply — *where* a view's pipeline lives
+ * (`transformSiteAt`, for the lens) or a step slot sits (`transformPlacementAt`, for
+ * the completion), and *what* a field-typed skeleton for each transform looks like.
+ *
+ * The **discoverable surface is a CodeLens** — `+ Add transform` on a view with no
+ * pipeline, and `+ filter`/`+ aggregate`/… on the array once it exists — mirroring
+ * the composition CodeLens (`spec-transform-actions`). The step-slot completion is a
+ * type-to-filter accelerator on the same catalog, not the primary way in.
+ *
+ * **Where a data transform can legally go (grammar-confirmed against the bundled VL
+ * schema — the `transform` array appears on 16 spec types, which reduce to one idea:
+ * every view node).** Three contexts exist; we scaffold only the one the schema
+ * leaves bare, on the same "add only what the schema lacks" rule as `spec-dataset-hints`:
+ *
+ * - **step (owned here)** — an element of a view's `transform: [ … ]` array. The
+ * array sits on *every* view node (unit, layer, facet, concat, repeat, and the
+ * root) — the node set `spec-cursor`/`spec-data` already call a "view" — so it
+ * inherits top-down: a step on a composition parent is **shared** (upstream of
+ * all its children); a step on a unit is **local**. The schema never offers a
+ * ready `{ "filter": … }` with your columns, so this is where we add value.
+ * - **inline (deferred to the schema)** — `bin`/`timeUnit`/`aggregate`/`sort` are
+ * also properties on an encoding channel's field def. The JSON schema already
+ * completes those keys *and* their enum values, so scaffolding them here would be
+ * a redundant second source.
+ * - **new pipeline (deferred — composition of two that already work)** — starting a
+ * `transform` on a bare view is the schema completing the `transform` key (→ empty
+ * array) followed by the step completion firing inside it. No separate surface needed.
+ *
+ * `transformPlacementAt` classifies the cursor from `jsonc-parser`'s error-tolerant
+ * tree, so it holds mid-edit. The catalog builders take the columns in scope (resolved
+ * by the service via `active-dataset`, exactly as the facet/repeat wraps do) and seed
+ * each placeholder with a type-appropriate default.
+ */
+
+import {
+ findNodeAtLocation,
+ 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 inferred data type. */
+export interface TypedField {
+ name: string;
+ type: ColumnType;
+}
+
+/** A `transform` step belongs to a composition parent (shared) or a unit (local). */
+type TransformScope = 'shared' | 'view';
+
+/**
+ * Where the cursor sits relative to a data transform:
+ * - `step` — in a `transform: [ … ]` element slot (offer the field-typed catalog),
+ * tagged with whether that array is a composition parent's (shared) or a unit's;
+ * - `none` — anywhere else (including inside an existing step object, where the
+ * schema owns key completion).
+ */
+export type TransformPlacement = { kind: 'step'; scope: TransformScope } | { kind: 'none' };
+
+/** A byte range into the spec text. */
+interface ByteRange {
+ offset: number;
+ length: number;
+}
+
+/**
+ * The data-transform site for the view the cursor is in — what the CodeLens needs to
+ * anchor and act. `view` is the enclosing view object's range (where an `+ Add
+ * transform` lens sits when there is no pipeline yet); `array` is the `transform[]`
+ * range and element count when one exists (where the per-step `+ filter` … lenses
+ * sit, and where a new step is spliced in). `scope` marks a composition parent's
+ * pipeline as shared.
+ */
+export interface TransformSite {
+ scope: TransformScope;
+ view: ByteRange;
+ array: (ByteRange & { count: number }) | null;
+}
+
+/** Composition keys whose presence on a view makes its transform *shared* upstream —
+ * the array operators (shared with `spec-cursor`/`spec-insert`) plus the single-child
+ * `facet`/`repeat`, so this stays one spelling of the operator set. */
+const COMPOSITION_KEYS = [...ARRAY_COMPOSITIONS, 'facet', 'repeat'];
+
+/** The object node at a jsonc location path, or null if the path isn't an object. */
+function objectNodeAt(tree: Node | undefined, path: ReadonlyArray): Node | null {
+ if (!tree) return null;
+ const node = findNodeAtLocation(tree, path as (string | number)[]);
+ return node && node.type === 'object' ? node : null;
+}
+
+/** The property keys of an object node. */
+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;
+}
+
+/** A view carrying a composition key feeds all its children, so its transform is shared. */
+function scopeFromKeys(keys: readonly string[]): TransformScope {
+ return keys.some((k) => COMPOSITION_KEYS.includes(k)) ? 'shared' : 'view';
+}
+
+/**
+ * Classify the cursor's data-transform context. Read from `jsonc-parser`'s
+ * error-tolerant tree so it holds mid-edit — a step slot is a `transform` array
+ * element position (`[ …, "transform", ]`) that is *not* a property key,
+ * which excludes the cursor being inside a step object editing its own keys (the
+ * schema owns that). `scope` reads the owning view's keys, defaulting to `view` when
+ * the tree can't resolve them.
+ */
+export function transformPlacementAt(text: string, offset: number): TransformPlacement {
+ const { path, isAtPropertyKey } = getLocation(text, offset);
+ if (
+ isAtPropertyKey ||
+ path.length < 2 ||
+ path[path.length - 2] !== 'transform' ||
+ typeof path[path.length - 1] !== 'number'
+ ) {
+ return { kind: 'none' };
+ }
+ const owner = objectNodeAt(parseTree(text), path.slice(0, -2));
+ return { kind: 'step', scope: scopeFromKeys(objectKeys(owner)) };
+}
+
+/** Is this object node a view a `transform` can attach to — the root, a composition, or a unit? */
+function isViewNode(node: Node): boolean {
+ if (!node.parent) return true; // the root spec
+ const keys = objectKeys(node);
+ if (keys.some((k) => COMPOSITION_KEYS.includes(k))) return true;
+ return keys.includes('mark') || keys.includes('encoding');
+}
+
+/** The `transform` array node of a view object, or null when it has none. */
+function transformArrayNode(view: Node): Node | null {
+ for (const prop of view.children ?? []) {
+ if (prop.type === 'property' && prop.children?.[0]?.value === 'transform') {
+ const value = prop.children[1];
+ return value?.type === 'array' ? value : null;
+ }
+ }
+ return null;
+}
+
+/**
+ * The transform site for the nearest view enclosing `offset` — climbed from the
+ * error-tolerant tree (so it holds mid-edit), or null when the cursor is in no view.
+ * The CodeLens reads this to place its lenses and to splice a new step; the byte
+ * ranges are turned into Monaco positions by the service.
+ */
+export function transformSiteAt(text: string, offset: number): TransformSite | null {
+ const tree = parseTree(text);
+ if (!tree) return null;
+ let node: Node | undefined = findNodeAtOffset(tree, offset, true);
+ while (node) {
+ if (node.type === 'object' && isViewNode(node)) break;
+ node = node.parent;
+ }
+ if (!node) return null;
+ const array = transformArrayNode(node);
+ return {
+ scope: scopeFromKeys(objectKeys(node)),
+ view: { offset: node.offset, length: node.length },
+ array: array
+ ? { offset: array.offset, length: array.length, count: array.children?.length ?? 0 }
+ : null,
+ };
+}
+
+// ─── Field-typed default picking ────────────────────────────────────────────────
+
+/** What kind of column a placeholder wants, so its default is type-appropriate. */
+type FieldNeed = 'any' | 'numeric' | 'temporal' | 'categorical';
+
+/** The first column matching a need, or null when none is in scope. */
+function pick(fields: readonly TypedField[], need: FieldNeed): string | null {
+ switch (need) {
+ case 'numeric':
+ return fields.find((f) => f.type === 'number')?.name ?? null;
+ case 'temporal':
+ return fields.find((f) => f.type === 'date')?.name ?? null;
+ case 'categorical':
+ return fields.find((f) => f.type === 'string' || f.type === 'boolean')?.name ?? null;
+ case 'any':
+ return fields[0]?.name ?? null;
+ }
+}
+
+/** A tab-stop's default text: the resolved column when one is in scope, else a name. */
+function def(fields: readonly TypedField[], need: FieldNeed, fallback: string): string {
+ return pick(fields, need) ?? fallback;
+}
+
+// ─── The transform catalog ──────────────────────────────────────────────────────
+
+/** One data transform the editor can scaffold as a `transform[]` step. */
+export interface DataTransform {
+ id: string;
+ /** Completion label. */
+ label: string;
+ /** One-line description (completion detail). */
+ detail: string;
+ /** The step snippet: compact JSON with `${n:default}` tab stops. */
+ step(fields: readonly TypedField[]): string;
+}
+
+/** The scaffoldable transforms, in rough order of how often they're reached for. */
+export const DATA_TRANSFORMS: readonly DataTransform[] = [
+ {
+ id: 'filter',
+ label: 'filter',
+ detail: 'Keep only rows matching a predicate',
+ step: (f) => `{ "filter": "datum.\${1:${def(f, 'any', 'field')}} > \${2:0}" }`,
+ },
+ {
+ id: 'calculate',
+ label: 'calculate',
+ detail: 'Derive a new field from an expression',
+ step: (f) => `{ "calculate": "datum.\${1:${def(f, 'any', 'field')}}", "as": "\${2:newField}" }`,
+ },
+ {
+ id: 'aggregate',
+ label: 'aggregate',
+ detail: 'Group rows and summarize (mean, sum, count…)',
+ step: (f) =>
+ `{ "aggregate": [{ "op": "\${1:mean}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_mean}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
+ },
+ {
+ id: 'bin',
+ label: 'bin',
+ detail: 'Bucket a quantitative field into bins',
+ step: (f) =>
+ `{ "bin": \${1:true}, "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_binned}" }`,
+ },
+ {
+ id: 'timeUnit',
+ label: 'timeUnit',
+ detail: 'Derive a calendar unit (year, month…) from a date',
+ step: (f) =>
+ `{ "timeUnit": "\${1:yearmonth}", "field": "\${2:${def(f, 'temporal', 'date')}}", "as": "\${3:${def(f, 'temporal', 'date')}_unit}" }`,
+ },
+ {
+ id: 'window',
+ label: 'window',
+ detail: 'Running totals, ranks, row numbers over a sort',
+ step: (f) =>
+ `{ "window": [{ "op": "\${1:row_number}", "as": "\${2:rank}" }], "sort": [{ "field": "\${3:${def(f, 'any', 'field')}}", "order": "descending" }] }`,
+ },
+ {
+ id: 'joinaggregate',
+ label: 'joinaggregate',
+ detail: 'Group aggregate that keeps every row (share of total)',
+ step: (f) =>
+ `{ "joinaggregate": [{ "op": "\${1:sum}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:total}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
+ },
+ {
+ id: 'fold',
+ label: 'fold',
+ detail: 'Reshape wide columns into key/value rows',
+ step: (f) =>
+ `{ "fold": ["\${1:${f[0]?.name ?? 'field1'}}", "\${2:${f[1]?.name ?? 'field2'}}"], "as": ["\${3:key}", "\${4:value}"] }`,
+ },
+ {
+ id: 'lookup',
+ label: 'lookup',
+ detail: 'Join fields from another dataset by a key',
+ step: () =>
+ `{ "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 };
+}