From a75ea5b59e97ef801ce7822a3ae9d60137d36ec9 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 28 Jun 2026 20:54:46 +0300 Subject: [PATCH] =?UTF-8?q?Editor:=20view-scoped=20data=20context=20?= =?UTF-8?q?=E2=80=94=20per-view=20dataset=20columns=20in=20hints=20and=20t?= =?UTF-8?q?ransforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/IMPLEMENTATION-PLAN.md | 6 +- .../architecture/08-vega-editor-techniques.md | 35 ++-- .../multi-view-data-model-scope.md | 23 +- src/app/services/active-dataset.test.ts | 113 ++++++++++ src/app/services/active-dataset.ts | 198 ++++++++++++------ src/app/services/spec-dataset-hints.ts | 30 ++- src/app/services/spec-transform-actions.ts | 60 ++++-- src/core/spec-cursor.ts | 10 + src/core/spec-data.test.ts | 49 ++++- src/core/spec-data.ts | 36 ++++ src/core/spec-fields.test.ts | 64 ++++-- src/core/spec-fields.ts | 50 +++-- src/core/spec-inline-data.test.ts | 41 ++-- src/core/spec-inline-data.ts | 76 +++---- 14 files changed, 547 insertions(+), 244 deletions(-) create mode 100644 src/app/services/active-dataset.test.ts diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 07fb8ac..8ec899b 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -75,9 +75,9 @@ This is the at-a-glance list; keep it in sync with them. **Next (flagged for build):** - **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) — - durable composition support across the data-facing features. M1 (Vega-Lite-fidelity - reference classifier, `core/spec-data`) is done; M2–M4 extend the editor data context, - Extract, and the data inspector to be view-scoped. + durable composition support across the data-facing features. Done: the Vega-Lite-fidelity + reference classifier (`core/spec-data`) and the view-scoped editor data context. Remaining: + per-view data inspection (`DataInspector` view selector), then view-scoped Extract. - **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) — a small set of curated starters, one per covered FT intent. Reshaped by 3C: a builder-openable starter must reference a dataset, so it ships paired sample datasets (or is diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index 3355c0a..3fadaf8 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -286,16 +286,22 @@ thin app-layer services. - `spec-transforms` — wrap a view in `layer`/`hconcat`/`vconcat`/`facet`/`repeat`; collapse a single-child composition (`unwrapSingleton`). Object-in/object-out. -- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range) and - `valueKeyAtOffset`/`stringValueAtOffset` (the JSON context at the cursor), over `jsonc-parser`. -- `spec-fields` — field names a spec's own transforms introduce (their `as`). -- `spec-inline-data` — the inline rows a spec carries (`data.values`, a `datasets` entry). +- `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). +- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range), + `pathAtOffset` (cursor → JSON path), and `valueKeyAtOffset`/`stringValueAtOffset` (the JSON + context at the cursor), over `jsonc-parser`. +- `spec-fields` — field names a spec's transforms introduce (their `as`), scoped to a + cursor's ancestor chain (`derivedFieldNamesAtPath`). +- `spec-inline-data` — the rows a specific `data` binding carries for profiling + (`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry). - `spec-insert` — composition arrays + appending a view to one. **Services (app, store-aware via `getState`):** `spec-transform-actions` (the wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion, -hover, inlay providers), `active-dataset` (`dataInfo()` — the columns/types/stats + derived -fields the draft sees). `SpecEditor` does the wiring. +hover, inlay providers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats +plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring. Decision rules: @@ -312,12 +318,17 @@ Decision rules: - **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar and palette use `executeEdits`. Both build the replacement through the same serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text. -- **Field source for hints.** `dataInfo()` reads columns from the named library dataset the - draft references, else profiles the spec's **inline data on the fly** (`spec-inline-data` + - `core/profile`) — a "ghost dataset" with nothing stored — and adds the spec's derived - fields. Memoized by draft text, since providers fire per keystroke and per scroll. Out of - scope: data-dependent derived columns (`pivot`/`lookup` output) and `url`/CSV-string inline - data, which need the pipeline run or format-aware parsing. +- **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data + binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a + parent's data unless it declares its own), then its columns: a named **library dataset** + (matched case-insensitively, like the renderer), else the binding's **inline rows** profiled + on the fly (`rowsForDataBinding` + `core/profile`) — a "ghost dataset" with nothing stored. + Derived fields come from that view's and its ancestors' transforms only + (`derivedFieldNamesAtPath`), so a sibling view's `calculate` does not leak in. Profiling is + memoized per (draft text, enclosing view), so the inlay provider's many per-line queries + profile once. A composed spec whose views bind different datasets therefore gets the right + columns per view. Out of scope: data-dependent derived columns (`pivot`/`lookup` output) and + `url`/CSV-string inline data, which need the pipeline run or format-aware parsing. - **No unknown-field diagnostic.** Hints are additive and forgiving, so over- or under-listing costs nothing; a "field not in data" squiggle would false-positive on every derived or data-dependent field, so there is deliberately none. diff --git a/docs/exploration/multi-view-data-model-scope.md b/docs/exploration/multi-view-data-model-scope.md index 584350a..37f7d4a 100644 --- a/docs/exploration/multi-view-data-model-scope.md +++ b/docs/exploration/multi-view-data-model-scope.md @@ -66,18 +66,21 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export - **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring `isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1. -- **M2 — view-scoped editor context** — pure `dataContextAtPath(spec, path)`: - climb the cursor's JSON path to the nearest enclosing `data` (honoring - Vega-Lite's parent→child data inheritance), classify it, and collect - ancestor-chain derived fields. Rework `active-dataset` to be cursor-scoped and - thread the offset through the three Monaco providers and the facet/repeat - defaults. Resolve columns for every form: library ref, inline `values`, - named-inline, self-defined `datasets`, url (no static rows), generator (none). +- **M2 — view-scoped editor context** ✅ — `dataBindingAtPath` (climb the cursor's + JSON path to the nearest enclosing `data`, honoring Vega-Lite's parent→child + inheritance) + `derivedFieldNamesAtPath` (ancestor-chain `as` outputs). + `active-dataset` is cursor-scoped (`dataInfoAt(text, offset)`), resolving columns + for every form (library ref case-insensitive, inline, named-inline, self-defined + `datasets`, url/generator → none); the three Monaco providers and the + facet/repeat defaults pass the cursor offset. +- **M4 — multi-view inspection** (pulled ahead of M3) — group the dataflow's + datasets into per-view input/resolved pairs (`result-data`) and add a view + selector to `DataInspector` (new interactive widget → `/council`). The data + inspector executes the live pipeline, so it is the place to review post-transform + rows (melt/fold/pivot/aggregate); today it surfaces one heuristic input/resolved + pair across the whole composition, with no per-view choice. - **M3 — view-scoped extract** — seed Extract from the focused view's inline data (reusing the cursor-scope machinery) and rewrite that view's `data`. -- **M4 — multi-view inspection** — group the dataflow's datasets into per-view - input/resolved pairs (`result-data`) and add a view selector to `DataInspector` - (new interactive widget → `/council`). Delivery is incremental, one milestone per commit, verified against real behavior. The consolidated data-model contract write-up into `docs/architecture` (05/08) diff --git a/src/app/services/active-dataset.test.ts b/src/app/services/active-dataset.test.ts new file mode 100644 index 0000000..1b4c821 --- /dev/null +++ b/src/app/services/active-dataset.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { createDataset } from '@core/dataset'; +import { useDatasetStore } from '../stores/DatasetStore'; +import { dataInfoAt } from './active-dataset'; + +/** Seed a library dataset, profiled from inline rows. */ +function seed(name: string, rows: Record[]): void { + useDatasetStore + .getState() + .add(createDataset({ name, data: rows, format: 'json', source: 'inline' })); +} + +/** An offset inside the first occurrence of `marker` in `text`. */ +function at(text: string, marker: string): number { + return text.indexOf(marker) + 1; +} + +const names = (cols: ReadonlyArray<{ name: string }>) => cols.map((c) => c.name).sort(); + +beforeEach(() => { + useDatasetStore.getState().reset(); +}); + +describe('dataInfoAt — view-scoped data context', () => { + test('resolves a library binding case-insensitively', () => { + seed('Sales', [{ region: 'W', revenue: 10 }]); + const text = '{ "data": { "name": "sales" }, "encoding": { "x": { "field": "MK" } } }'; + const info = dataInfoAt(text, at(text, 'MK')); + expect(info.name).toBe('Sales'); + expect(names(info.columnTypes)).toEqual(['region', 'revenue']); + }); + + test('each layer sees its own dataset, not a sibling view’s', () => { + seed('Sales', [{ region: 'W', revenue: 10 }]); + seed('Regions', [{ region: 'W', population: 100 }]); + const text = JSON.stringify( + { + layer: [ + { data: { name: 'Sales' }, encoding: { x: { field: 'sales_mk' } } }, + { data: { name: 'Regions' }, encoding: { y: { field: 'regions_mk' } } }, + ], + }, + null, + 2, + ); + const inLayer0 = dataInfoAt(text, at(text, 'sales_mk')); + const inLayer1 = dataInfoAt(text, at(text, 'regions_mk')); + expect(inLayer0.name).toBe('Sales'); + expect(names(inLayer0.columnTypes)).toEqual(['region', 'revenue']); + expect(inLayer1.name).toBe('Regions'); + expect(names(inLayer1.columnTypes)).toEqual(['population', 'region']); + }); + + test('a child without its own data inherits the parent binding', () => { + seed('Sales', [{ region: 'W', revenue: 10 }]); + const text = JSON.stringify( + { data: { name: 'Sales' }, layer: [{ encoding: { x: { field: 'mk' } } }] }, + null, + 2, + ); + const info = dataInfoAt(text, at(text, '"mk"')); + expect(info.name).toBe('Sales'); + expect(names(info.columnTypes)).toEqual(['region', 'revenue']); + }); + + test('profiles an inline binding (ghost dataset, no name)', () => { + const text = + '{ "data": { "values": [{ "a": 1, "b": 2 }] }, "encoding": { "x": { "field": "MK" } } }'; + const info = dataInfoAt(text, at(text, 'MK')); + expect(info.name).toBeNull(); + expect(names(info.columnTypes)).toEqual(['a', 'b']); + }); + + test('profiles a self-defined top-level datasets binding', () => { + const text = JSON.stringify( + { + datasets: { local: [{ c: 1, d: 2 }] }, + data: { name: 'local' }, + encoding: { x: { field: 'mk' } }, + }, + null, + 2, + ); + const info = dataInfoAt(text, at(text, '"mk"')); + expect(info.name).toBeNull(); + expect(names(info.columnTypes)).toEqual(['c', 'd']); + }); + + test('offers ancestor-derived fields, not a sibling view’s', () => { + seed('Sales', [{ region: 'W', revenue: 10 }]); + const text = JSON.stringify( + { + data: { name: 'Sales' }, + transform: [{ calculate: 'datum.revenue * 2', as: 'shared' }], + layer: [ + { transform: [{ calculate: 'x', as: 'inner' }], encoding: { x: { field: 'mk0' } } }, + { transform: [{ calculate: 'y', as: 'sibling' }], encoding: { y: { field: 'mk1' } } }, + ], + }, + null, + 2, + ); + const fields = dataInfoAt(text, at(text, 'mk0')).fields.map((f) => f.name); + expect(fields).toContain('shared'); // top-level (inherited) + expect(fields).toContain('inner'); // this view + expect(fields).not.toContain('sibling'); // the other layer + }); + + test('a url/generator binding yields no static columns', () => { + const url = '{ "data": { "url": "x.csv" }, "encoding": { "x": { "field": "MK" } } }'; + expect(dataInfoAt(url, at(url, 'MK')).columnTypes).toEqual([]); + }); +}); diff --git a/src/app/services/active-dataset.ts b/src/app/services/active-dataset.ts index ff34991..07198fa 100644 --- a/src/app/services/active-dataset.ts +++ b/src/app/services/active-dataset.ts @@ -1,29 +1,35 @@ /** - * The data context of the active draft (docs/architecture/08 → editor - * augmentation): the columns/types/stats the spec can reference, and the fields - * the editor hints offer. One resolver, shared by the transform actions - * (facet/repeat field defaults) and the dataset-aware hints + * The data context of the active draft **at the cursor** (docs/architecture/08 → + * editor augmentation): the columns/types/stats the spec can reference where the + * cursor sits, and the fields the editor hints offer. One resolver, shared by the + * transform actions (facet/repeat field defaults) and the dataset-aware hints * (completion/hover/inlay). * - * Source, in order: a **named library dataset** the draft references (its stored - * profile), else the spec's **inline data** profiled on the fly (the "ghost - * dataset" — `core/spec-inline-data` + `core/profile`, nothing stored). On top of - * either, the spec's **derived** fields (transform `as`, `core/spec-fields`) are - * added, so a `calculate` output is offered alongside the data columns. + * View-scoped: a composed spec (layer/concat/facet/repeat) can bind a different + * dataset per view, and Vega-Lite inherits a parent view's data into its children. + * So the context is resolved at a JSON path — the data binding of the nearest + * enclosing view (`dataBindingAtPath`), plus the fields derived by that view's and + * its ancestors' transforms (`derivedFieldNamesAtPath`). * - * The result is memoized by draft text: providers fire often (every keystroke - * completes, inlay refreshes on scroll), and profiling inline data each time would - * be wasteful. App layer — reads stores via `getState`, outside React. + * Source columns, in order: a named **library dataset** the binding references + * (its stored profile, matched case-insensitively like the renderer), else the + * binding's **inline rows** profiled on the fly — inline `values` or a self-defined + * top-level `datasets` entry (the "ghost dataset", `core/spec-inline-data` + + * `core/profile`, nothing stored). URL and generator bindings have no static + * columns. + * + * Profiling is memoized per (draft text, enclosing view) so the inlay provider — + * which queries many field lines at one draft — never re-profiles the same inline + * data. App layer — reads stores via `getState`, outside React. */ -import type { Dataset } from '@core/dataset'; import { type ColumnStats, profileData } from '@core/profile'; -import { derivedFieldNames } from '@core/spec-fields'; -import { inlineDataRows } from '@core/spec-inline-data'; -import { extractDatasetRefs } from '@core/spec-refs'; +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 { ColumnType } from '@core/type-inference'; import { useDatasetStore } from '../stores/DatasetStore'; -import { useSnippetStore } from '../stores/SnippetStore'; /** A field offered to the editor hints. */ export interface FieldHint { @@ -34,7 +40,7 @@ export interface FieldHint { derived: boolean; } -/** Everything the hints need about the active draft's data. */ +/** Everything the hints need about the active draft's data at a cursor. */ export interface DataInfo { /** The library dataset's name, or null for inline data (the ghost dataset). */ name: string | null; @@ -44,7 +50,15 @@ export interface DataInfo { fields: ReadonlyArray; } -const EMPTY: DataInfo = { name: null, columnTypes: [], columnStats: [], fields: [] }; +/** The source columns of one view's data binding (the expensive, cached part). */ +interface SourceColumns { + name: string | null; + columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>; + columnStats: ReadonlyArray; +} + +const EMPTY_SOURCE: SourceColumns = { name: null, columnTypes: [], columnStats: [] }; +const EMPTY: DataInfo = { ...EMPTY_SOURCE, fields: [] }; function safeParse(text: string): unknown { try { @@ -54,74 +68,122 @@ function safeParse(text: string): unknown { } } -/** The first library dataset the draft references, or null. */ -function libraryDataset(draftText: string): Dataset | null { - const refs = extractDatasetRefs(draftText); - if (refs.length === 0) return null; - const { datasets } = useDatasetStore.getState(); - for (const ref of refs) { - const ds = datasets.find((d) => d.name === ref); - if (ds) return ds; - } - return null; +// The parsed draft, memoized by text — both the binding walk and the derived-field +// walk read it, and providers fire often at one stable draft. +let parseCache: { text: string; spec: unknown } | null = null; +function parsedSpec(text: string): unknown { + if (parseCache?.text === text) return parseCache.spec; + const spec = safeParse(text); + parseCache = { text, spec }; + return spec; } -function compute(draftText: string): DataInfo { - const spec = safeParse(draftText); - - // Source columns: a named library dataset wins; otherwise profile inline data. - let name: string | null = null; - let columnTypes: ReadonlyArray<{ name: string; type: ColumnType }> = []; - let columnStats: ReadonlyArray = []; - const library = libraryDataset(draftText); - if (library && library.columnTypes.length > 0) { - ({ name, columnTypes, columnStats } = library); - } else { - const rows = inlineDataRows(spec); - if (rows) { - const profile = profileData(rows, 0); - columnTypes = profile.columnTypes; - columnStats = profile.columnStats; - } +/** The stored profile of the library dataset a binding references, or null. */ +function libraryProfile(refName: string): SourceColumns | null { + const lower = refName.toLowerCase(); + const ds = useDatasetStore.getState().datasets.find((d) => d.name.toLowerCase() === lower); + if (ds && ds.columnTypes.length > 0) { + return { name: ds.name, columnTypes: ds.columnTypes, columnStats: ds.columnStats }; } + return null; // unknown name, or a dataset with no profiled columns yet +} - const fields: FieldHint[] = columnTypes.map((c) => ({ +/** Source columns for one binding: library profile, else inline rows profiled. */ +function resolveSource(spec: unknown, data: unknown): SourceColumns { + const refName = libraryRefName(data, selfDefinedNames(spec)); + if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE; + const rows = rowsForDataBinding(spec, data); + if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values + const profile = profileData(rows, 0); + return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats }; +} + +// Source columns memoized by (text, enclosing view). Keyed by the binding's anchor +// path so the inlay provider's many per-line queries within one view profile once. +// A re-import that changes a dataset's columns under unchanged text serves stale +// hints until the next keystroke — harmless, since hints are additive. +let sourceCache: { text: string; byAnchor: Map } | null = null; +function sourceColumns( + text: string, + spec: unknown, + anchorKey: string, + data: unknown, +): SourceColumns { + if (!sourceCache || sourceCache.text !== text) sourceCache = { text, byAnchor: new Map() }; + const hit = sourceCache.byAnchor.get(anchorKey); + if (hit) return hit; + const result = resolveSource(spec, data); + sourceCache.byAnchor.set(anchorKey, result); + return result; +} + +/** + * The shared resolve step: the source columns of the binding in scope at `offset`, + * with the parsed spec and cursor path so callers can layer derived fields on top + * without re-parsing or re-walking. + */ +function resolveAt( + text: string, + offset: number, +): { + source: SourceColumns; + spec: unknown; + path: ReadonlyArray; +} { + const spec = parsedSpec(text); + const path = pathAtOffset(text, offset); + const binding = dataBindingAtPath(spec, path); + const source = binding + ? sourceColumns(text, spec, JSON.stringify(binding.anchorPath), binding.data) + : EMPTY_SOURCE; + return { source, spec, path }; +} + +/** Source columns of the binding in scope at `offset` — no derived-field merge. */ +function sourceAt(text: string, offset: number): SourceColumns { + return text.trim() === '' ? EMPTY_SOURCE : resolveAt(text, offset).source; +} + +/** The data context at `offset` in the editor's draft `text`. */ +export function dataInfoAt(text: string, offset: number): DataInfo { + if (text.trim() === '') return EMPTY; + const { source, spec, path } = resolveAt(text, offset); + + const fields: FieldHint[] = source.columnTypes.map((c) => ({ name: c.name, type: c.type, derived: false, })); const seen = new Set(fields.map((f) => f.name)); - for (const derived of derivedFieldNames(spec)) { + for (const derived of derivedFieldNamesAtPath(spec, path)) { if (!seen.has(derived)) { fields.push({ name: derived, type: null, derived: true }); seen.add(derived); } } - return { name, columnTypes, columnStats, fields }; + return { + name: source.name, + columnTypes: source.columnTypes, + columnStats: source.columnStats, + fields, + }; } -// Keyed by draft text alone: this assumes a bound dataset's profile is stable -// for a given draft. A re-import that changes columns under unchanged text serves -// stale hints until the next keystroke — harmless, since hints are additive. -let cache: { text: string; info: DataInfo } | null = null; - -/** The active draft's data context, memoized by draft text. */ -export function dataInfo(): DataInfo { - const text = useSnippetStore.getState().draftText; - if (text.trim() === '') return EMPTY; - if (cache && cache.text === text) return cache.info; - const info = compute(text); - cache = { text, info }; - return info; +/** Source columns + derived fields available at `offset` (for completion). */ +export function availableFieldsAt(text: string, offset: number): ReadonlyArray { + return dataInfoAt(text, offset).fields; } -/** Source columns + inferred types available to the draft (for facet/repeat defaults). */ -export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> { - return dataInfo().columnTypes; +/** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */ +export function boundColumnsAt( + text: string, + offset: number, +): ReadonlyArray<{ name: string; type: ColumnType }> { + return sourceAt(text, offset).columnTypes; } -/** Source columns plus the spec's derived fields (for completion/inlay). */ -export function availableFields(): ReadonlyArray { - return dataInfo().fields; +/** The inferred type of a source field `name` at `offset`, or null (for inlay). */ +export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null { + return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null; } diff --git a/src/app/services/spec-dataset-hints.ts b/src/app/services/spec-dataset-hints.ts index fea03a4..64a9c70 100644 --- a/src/app/services/spec-dataset-hints.ts +++ b/src/app/services/spec-dataset-hints.ts @@ -27,7 +27,13 @@ import { validateExpression } from '@core/expr-validate'; import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor'; import type { ColumnType } from '@core/type-inference'; import { useSnippetStore } from '../stores/SnippetStore'; -import { availableFields, dataInfo, type DataInfo, type FieldHint } from './active-dataset'; +import { + availableFieldsAt, + dataInfoAt, + fieldTypeAt, + type DataInfo, + type FieldHint, +} from './active-dataset'; /** Property values that reference a data field (where column names belong). */ const FIELD_KEYS = new Set(['field', 'groupby']); @@ -65,9 +71,11 @@ export function configureSpecDatasetHints(): void { provideCompletionItems(model, position) { // Field suggestions only edit the draft; nothing to offer on the published view. if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] }; - const key = valueKeyAtOffset(model.getValue(), model.getOffsetAt(position)); + const text = model.getValue(); + const offset = model.getOffsetAt(position); + const key = valueKeyAtOffset(text, offset); if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] }; - const fields = availableFields(); + const fields = availableFieldsAt(text, offset); if (fields.length === 0) return { suggestions: [] }; const word = model.getWordUntilPosition(position); @@ -92,7 +100,7 @@ export function configureSpecDatasetHints(): void { }); // All three providers annotate the draft only: they derive their field set from - // the draft buffer (dataInfo), so gating to the draft view keeps the hints + // the draft buffer (dataInfoAt), so gating to the draft view keeps the hints // consistent with the text they are computed from. The published view is a // read-only reference, where field hints are marginal. monaco.languages.registerHoverProvider('json', { @@ -119,10 +127,10 @@ export function configureSpecDatasetHints(): void { } } - // Over a field name: its type + stats. + // Over a field name: its type + stats, resolved at this view's data binding. const word = model.getWordAtPosition(position); if (word) { - const info = dataInfo(); + const info = dataInfoAt(text, offset); const hint = info.fields.find((f) => f.name === word.word); if (hint) { return { @@ -143,15 +151,19 @@ export function configureSpecDatasetHints(): void { monaco.languages.registerInlayHintsProvider('json', { provideInlayHints(model, range) { if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} }; - const types = new Map(availableFields().map((f) => [f.name, f.type])); - if (types.size === 0) return { hints: [], dispose() {} }; + const text = model.getValue(); const hints: monaco.languages.InlayHint[] = []; for (let line = range.startLineNumber; line <= range.endLineNumber; line++) { // First `field` per line; the compact format keeps each encoding channel // (and its lone field) on its own line, so one match per line suffices. const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line)); if (!match) continue; - const type = types.get(match[1]); + // Resolve the type at this field's own data binding — views in a + // composition can bind different datasets. The offset points inside the + // field's value so the path resolves to its enclosing view. + const valueCol = match.index + match[0].length - match[1].length; + const offset = model.getOffsetAt({ lineNumber: line, column: valueCol }); + const type = fieldTypeAt(text, offset, match[1]); if (!type) continue; // unknown or derived (no type to annotate) hints.push({ position: { lineNumber: line, column: match.index + match[0].length + 1 }, diff --git a/src/app/services/spec-transform-actions.ts b/src/app/services/spec-transform-actions.ts index 3e1cfc8..19d302e 100644 --- a/src/app/services/spec-transform-actions.ts +++ b/src/app/services/spec-transform-actions.ts @@ -43,7 +43,7 @@ import { } from '@core/spec-transforms'; import { notify } from '../stores/NotificationStore'; import { useSnippetStore } from '../stores/SnippetStore'; -import { boundColumns } from './active-dataset'; +import { boundColumnsAt } from './active-dataset'; import { parseSpecObject } from './spec-config-actions'; /** The composition operators the wrap actions offer. */ @@ -53,6 +53,8 @@ type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat'; interface Scope { range: monaco.Range; text: string; + /** Whole-document offset of the focused view, for resolving its data binding. */ + offset: number; /** Column the slice starts at (0-based), so re-indented output stays aligned. */ baseCol: number; } @@ -63,29 +65,31 @@ const isEmptyRange = (r: monaco.IRange): boolean => /** The whole document, as a scope. */ function wholeDocument(model: monaco.editor.ITextModel): Scope { const range = model.getFullModelRange(); - return { range, text: model.getValue(), baseCol: 0 }; + return { range, text: model.getValue(), offset: 0, baseCol: 0 }; } /** * The slice a transform acts on: an explicit selection if present; else the view - * the cursor sits in (core/spec-cursor); else the whole document. + * the cursor sits in (core/spec-cursor); else the whole document. `offset` is the + * cursor/selection-start position in the whole document, used to resolve the + * focused view's data binding (facet/repeat field defaults). */ function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope { if (!range) return wholeDocument(model); - if (!isEmptyRange(range)) { - const r = monaco.Range.lift(range); - return { range: r, text: model.getValueInRange(r), baseCol: r.startColumn - 1 }; - } const offset = model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn, }); + if (!isEmptyRange(range)) { + const r = monaco.Range.lift(range); + return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 }; + } const node = findViewRange(model.getValue(), offset); if (!node) return wholeDocument(model); const start = model.getPositionAt(node.offset); const end = model.getPositionAt(node.offset + node.length); const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column); - return { range: r, text: model.getValueInRange(r), baseCol: start.column - 1 }; + return { range: r, text: model.getValueInRange(r), offset, baseCol: start.column - 1 }; } /** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */ @@ -106,8 +110,8 @@ function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonO } /** A categorical column to facet by (first nominal/ordinal), or a placeholder. */ -function defaultFacet(): { field: string; type: string } { - const cols = boundColumns(); +function defaultFacet(text: string, offset: number): { field: string; type: string } { + const cols = boundColumnsAt(text, offset); const categorical = cols.find((c) => { const t = defaultFieldType(c.type); return t === 'nominal' || t === 'ordinal'; @@ -119,8 +123,12 @@ function defaultFacet(): { field: string; type: string } { } /** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */ -function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } { - const cols = boundColumns(); +function defaultRepeat( + spec: JsonObject, + text: string, + offset: number, +): { fields: string[]; channel: string | null } { + const cols = boundColumnsAt(text, offset); const numeric = cols .filter((c) => defaultFieldType(c.type) === 'quantitative') .map((c) => c.name); @@ -130,8 +138,12 @@ function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel }; } -/** Apply a wrap of the given kind to the parsed spec. */ -function buildNext(spec: JsonObject, kind: WrapKind): JsonObject { +/** + * Apply a wrap of the given kind to the parsed (scoped) spec. `text`/`offset` are + * the whole document and the focused view's position, used to resolve that view's + * data binding for the facet/repeat field defaults. + */ +function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): JsonObject { switch (kind) { case 'layer': return wrapInLayer(spec); @@ -140,11 +152,11 @@ function buildNext(spec: JsonObject, kind: WrapKind): JsonObject { case 'vconcat': return wrapInConcat(spec, 'v'); case 'facet': { - const { field, type } = defaultFacet(); + const { field, type } = defaultFacet(text, offset); return wrapInFacet(spec, field, type); } case 'repeat': { - const { fields, channel } = defaultRepeat(spec); + const { fields, channel } = defaultRepeat(spec, text, offset); return wrapInRepeat(spec, fields, channel); } } @@ -190,7 +202,8 @@ export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapK const target = resolveTarget(editor); if (!target) return; const { model, scope, spec } = target; - writeBack(editor, scope.range, formatScoped(model, scope, buildNext(spec, kind))); + const next = buildNext(spec, kind, model.getValue(), scope.offset); + writeBack(editor, scope.range, formatScoped(model, scope, next)); notify({ kind: 'success', title: 'View wrapped', @@ -381,13 +394,16 @@ export function configureSpecTransformCodeActions(): void { return empty; } if (!isJsonObject(spec)) return empty; + const viewSpec = spec; + const fullText = model.getValue(); + const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset); const actions: monaco.languages.CodeAction[] = [ - editAction(model, scope, 'Wrap view in a layer', buildNext(spec, 'layer')), - editAction(model, scope, 'Wrap view in horizontal concat', buildNext(spec, 'hconcat')), - editAction(model, scope, 'Wrap view in vertical concat', buildNext(spec, 'vconcat')), - editAction(model, scope, 'Wrap view in a facet', buildNext(spec, 'facet')), - editAction(model, scope, 'Wrap view in a repeat', buildNext(spec, 'repeat')), + editAction(model, scope, 'Wrap view in a layer', build('layer')), + editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')), + editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')), + editAction(model, scope, 'Wrap view in a facet', build('facet')), + editAction(model, scope, 'Wrap view in a repeat', build('repeat')), ]; const collapsed = unwrapSingleton(spec); if (collapsed) diff --git a/src/core/spec-cursor.ts b/src/core/spec-cursor.ts index 7dae7f1..4b45ea2 100644 --- a/src/core/spec-cursor.ts +++ b/src/core/spec-cursor.ts @@ -70,6 +70,16 @@ export function findViewRange(text: string, offset: number): NodeRange | null { return null; } +/** + * The JSON path (object keys and array indices, root → cursor) of the node at + * `offset`. Drives the view-scoped data context: the path is walked to find the + * nearest enclosing view's data binding and ancestor transforms + * (`core/spec-data`, `core/spec-fields`). Error-tolerant, so it works mid-edit. + */ +export function pathAtOffset(text: string, offset: number): (string | number)[] { + return [...getLocation(text, offset).path]; +} + /** * The property key whose *value* the cursor sits in, or null when the cursor is * on a key, at the top level, or otherwise not in a value. For an array element diff --git a/src/core/spec-data.test.ts b/src/core/spec-data.test.ts index 8cdd431..a247bea 100644 --- a/src/core/spec-data.test.ts +++ b/src/core/spec-data.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { classifyData, libraryRefName, selfDefinedNames } from './spec-data'; +import { classifyData, dataBindingAtPath, libraryRefName, selfDefinedNames } from './spec-data'; describe('classifyData', () => { test('classifies the four Vega-Lite data shapes', () => { @@ -61,3 +61,50 @@ describe('libraryRefName', () => { expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull(); }); }); + +describe('dataBindingAtPath', () => { + test('finds the top-level data when no view overrides it', () => { + const spec = { data: { name: 'Sales' }, layer: [{ mark: 'bar', encoding: { x: {} } }] }; + // Cursor deep inside the first layer's encoding — inherits the top-level data. + const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'x']); + expect(binding).toEqual({ data: { name: 'Sales' }, anchorPath: [] }); + }); + + test('the nearest enclosing view wins (Vega-Lite data inheritance)', () => { + const spec = { + data: { name: 'Top' }, + layer: [{ data: { name: 'Inner' }, encoding: { y: { field: 'a' } } }], + }; + const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'y', 'field']); + expect(binding).toEqual({ data: { name: 'Inner' }, anchorPath: ['layer', 0] }); + }); + + test('a sibling view does not leak its data', () => { + const spec = { + layer: [ + { data: { name: 'A' }, mark: 'bar' }, + { data: { name: 'B' }, mark: 'line' }, + ], + }; + expect(dataBindingAtPath(spec, ['layer', 1, 'mark'])).toEqual({ + data: { name: 'B' }, + anchorPath: ['layer', 1], + }); + }); + + test('resolves through a facet/repeat child spec', () => { + const spec = { facet: { field: 'g' }, spec: { data: { name: 'Child' }, mark: 'point' } }; + expect(dataBindingAtPath(spec, ['spec', 'mark'])).toEqual({ + data: { name: 'Child' }, + anchorPath: ['spec'], + }); + }); + + test('returns null when no ancestor on the path declares data', () => { + expect(dataBindingAtPath({ mark: 'bar' }, ['mark'])).toBeNull(); + }); + + test('descends defensively past a non-object on the path', () => { + expect(dataBindingAtPath({ mark: 'bar' }, ['mark', 'type', 'nope'])).toBeNull(); + }); +}); diff --git a/src/core/spec-data.ts b/src/core/spec-data.ts index c931c06..a9c3ea3 100644 --- a/src/core/spec-data.ts +++ b/src/core/spec-data.ts @@ -72,3 +72,39 @@ export function libraryRefName(data: unknown, selfDefined: ReadonlySet): const { name } = data as { name: string }; return selfDefined.has(name) ? null : name; } + +/** A `data` block in scope at a cursor, with the path prefix where it was found. */ +export interface DataBinding { + /** The raw `data` value (classify it with `classifyData` / `libraryRefName`). */ + data: unknown; + /** The path prefix of the view that declares this `data` — `[]` for the root. */ + anchorPath: ReadonlyArray; +} + +/** + * The data binding in scope at a JSON `path` into the spec — the `data` block of + * the *nearest enclosing view* (docs/architecture/08 → editor augmentation). This + * implements Vega-Lite's data inheritance: a layer/concat/facet/repeat child + * inherits its parent's data unless it declares its own, so the binding is the + * `data` of the deepest object along the path that has one. Returns `null` when no + * ancestor on the path declares `data`. + * + * `path` is a jsonc-parser location path (object keys and array indices, root to + * cursor). The walk descends it defensively, stopping at any non-object/array. + */ +export function dataBindingAtPath( + spec: unknown, + path: ReadonlyArray, +): DataBinding | null { + let node: unknown = spec; + let binding: DataBinding | null = null; + if (isJsonObject(node) && 'data' in node) binding = { data: node.data, anchorPath: [] }; + for (let i = 0; i < path.length; i++) { + if (node === null || typeof node !== 'object') break; + node = (node as Record)[path[i]]; + if (isJsonObject(node) && 'data' in node) { + binding = { data: node.data, anchorPath: path.slice(0, i + 1) }; + } + } + return binding; +} diff --git a/src/core/spec-fields.test.ts b/src/core/spec-fields.test.ts index d8fd94c..d3d4b20 100644 --- a/src/core/spec-fields.test.ts +++ b/src/core/spec-fields.test.ts @@ -1,52 +1,70 @@ import { describe, expect, it } from 'vitest'; -import { derivedFieldNames } from './spec-fields'; +import { derivedFieldNamesAtPath } from './spec-fields'; + +describe('derivedFieldNamesAtPath', () => { + const spec = { + transform: [{ calculate: 'x', as: 'shared' }], + layer: [ + { transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' }, + { transform: [{ calculate: 'z', as: 'sibling' }], mark: 'line' }, + ], + }; -describe('derivedFieldNames', () => { it('collects calculate / timeUnit / bin as-names', () => { - const spec = { + const s = { transform: [ { calculate: 'datum.a + 1', as: 'plusOne' }, { timeUnit: 'month', field: 'date', as: 'mo' }, { bin: true, field: 'x', as: ['x_start', 'x_end'] }, ], }; - expect(derivedFieldNames(spec).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']); + expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']); }); it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => { - const spec = { + const s = { transform: [ { aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] }, { window: [{ op: 'rank', as: 'rk' }] }, { joinaggregate: [{ op: 'sum', field: 'v', as: 'total' }] }, ], }; - expect(derivedFieldNames(spec).sort()).toEqual(['meanV', 'rk', 'total']); + expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['meanV', 'rk', 'total']); }); it('defaults fold output to key/value when as is omitted', () => { - expect(derivedFieldNames({ transform: [{ fold: ['a', 'b'] }] }).sort()).toEqual([ + expect(derivedFieldNamesAtPath({ transform: [{ fold: ['a', 'b'] }] }, []).sort()).toEqual([ 'key', 'value', ]); - expect(derivedFieldNames({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }).sort()).toEqual([ - 'k', - 'v', - ]); - }); - - it('recurses into per-view transforms and de-duplicates', () => { - const spec = { - transform: [{ calculate: 'x', as: 'shared' }], - layer: [ - { transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' }, - { transform: [{ calculate: 'z', as: 'shared' }], mark: 'line' }, - ], - }; - expect(derivedFieldNames(spec).sort()).toEqual(['inner', 'shared']); + expect( + derivedFieldNamesAtPath({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }, []).sort(), + ).toEqual(['k', 'v']); }); it('returns nothing for a spec with no transforms', () => { - expect(derivedFieldNames({ mark: 'bar', encoding: { x: { field: 'a' } } })).toEqual([]); + expect(derivedFieldNamesAtPath({ mark: 'bar', encoding: { x: { field: 'a' } } }, [])).toEqual( + [], + ); + }); + + it('includes ancestor transforms but not sibling-view transforms', () => { + // Inside layer 0: sees its own `inner` and the inherited top-level `shared`, + // but never layer 1's `sibling`. + expect(derivedFieldNamesAtPath(spec, ['layer', 0, 'mark']).sort()).toEqual(['inner', 'shared']); + expect(derivedFieldNamesAtPath(spec, ['layer', 1, 'mark']).sort()).toEqual([ + 'shared', + 'sibling', + ]); + }); + + it('at the root sees only the top-level transforms', () => { + expect(derivedFieldNamesAtPath(spec, [])).toEqual(['shared']); + }); + + it('descends defensively past a non-object on the path', () => { + expect(derivedFieldNamesAtPath(spec, ['transform', 0, 'calculate', 'nope'])).toEqual([ + 'shared', + ]); }); }); diff --git a/src/core/spec-fields.ts b/src/core/spec-fields.ts index 94beef1..27f7809 100644 --- a/src/core/spec-fields.ts +++ b/src/core/spec-fields.ts @@ -8,8 +8,9 @@ * offered too. This collects the statically-named ones: every transform `as` * (`calculate`, `timeUnit`, `bin`, `stack`, `fold`, `flatten`, `regression`, …) * and the nested `as` of the op-list transforms (`aggregate`, `window`, - * `joinaggregate`). Transforms can sit at the top level or inside any view, so - * the walk recurses the whole spec. + * `joinaggregate`), scoped to the transforms in effect at a cursor — the enclosing + * view's and its ancestors' (Vega-Lite applies a parent view's transforms upstream + * of its children; a sibling view's do not apply). * * Out of scope by design: **data-dependent** derived columns — `pivot`'s * one-column-per-value output and `lookup`'s imported fields — which only exist @@ -44,25 +45,36 @@ function collectFromTransform(transform: unknown, names: Set): void { } } +/** Add every field name a node's own `transform` array introduces. */ +function collectNodeTransforms(node: unknown, names: Set): void { + if ( + node !== null && + typeof node === 'object' && + Array.isArray((node as { transform?: unknown }).transform) + ) { + for (const t of (node as { transform: unknown[] }).transform) collectFromTransform(t, names); + } +} + /** - * Every field name the spec derives via its transforms, de-duplicated. Accepts a - * parsed spec object (callers parse the draft once). + * The fields derived by transforms *in scope* at a JSON `path` into the spec — the + * `as` outputs of every transform on the cursor's enclosing view and its ancestors + * (docs/architecture/08 → editor augmentation). Vega-Lite applies a parent view's + * transforms upstream of its children, so a field a parent's `calculate` creates is + * visible to a nested view; sibling views' transforms are not. The walk descends + * `path` (a jsonc-parser location path) defensively, collecting along the way. */ -export function derivedFieldNames(spec: unknown): string[] { +export function derivedFieldNamesAtPath( + spec: unknown, + path: ReadonlyArray, +): string[] { const names = new Set(); - const walk = (node: unknown): void => { - if (Array.isArray(node)) { - for (const item of node) walk(item); - return; - } - if (node !== null && typeof node === 'object') { - const obj = node as Record; - if (Array.isArray(obj.transform)) { - for (const t of obj.transform) collectFromTransform(t, names); - } - for (const key of Object.keys(obj)) walk(obj[key]); - } - }; - walk(spec); + let node: unknown = spec; + collectNodeTransforms(node, names); + for (const key of path) { + if (node === null || typeof node !== 'object') break; + node = (node as Record)[key]; + collectNodeTransforms(node, names); + } return [...names]; } diff --git a/src/core/spec-inline-data.test.ts b/src/core/spec-inline-data.test.ts index 19d799c..5ddeb7e 100644 --- a/src/core/spec-inline-data.test.ts +++ b/src/core/spec-inline-data.test.ts @@ -1,34 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { inlineDataRows } from './spec-inline-data'; +import { rowsForDataBinding } from './spec-inline-data'; -describe('inlineDataRows', () => { - it('reads top-level data.values', () => { - const rows = inlineDataRows({ data: { values: [{ a: 1 }, { a: 2 }] }, mark: 'bar' }); - expect(rows).toEqual([{ a: 1 }, { a: 2 }]); +describe('rowsForDataBinding', () => { + it('reads inline values from the given binding', () => { + const spec = { data: { values: [{ a: 1 }, { a: 2 }] } }; + expect(rowsForDataBinding(spec, spec.data)).toEqual([{ a: 1 }, { a: 2 }]); }); - it('falls back to a nested data.values when there is no top-level one', () => { - const spec = { layer: [{ data: { values: [{ b: 1 }] }, mark: 'bar' }] }; - expect(inlineDataRows(spec)).toEqual([{ b: 1 }]); + it('resolves a self-defined named binding against top-level datasets', () => { + const spec = { datasets: { ds: [{ c: 1 }, { c: 2 }] }, data: { name: 'ds' } }; + expect(rowsForDataBinding(spec, { name: 'ds' })).toEqual([{ c: 1 }, { c: 2 }]); }); - it('prefers top-level over nested', () => { - const spec = { - data: { values: [{ top: 1 }] }, - layer: [{ data: { values: [{ nested: 1 }] } }], - }; - expect(inlineDataRows(spec)).toEqual([{ top: 1 }]); - }); - - it('reads a top-level datasets entry when no data.values exist', () => { - const spec = { datasets: { ds: [{ c: 1 }] }, data: { name: 'ds' } }; - expect(inlineDataRows(spec)).toEqual([{ c: 1 }]); - }); - - it('returns null for URL data, empty values, or non-object rows', () => { - expect(inlineDataRows({ data: { url: 'x.csv' } })).toBeNull(); - expect(inlineDataRows({ data: { values: [] } })).toBeNull(); - expect(inlineDataRows({ data: { values: [1, 2, 3] } })).toBeNull(); - expect(inlineDataRows({ mark: 'bar' })).toBeNull(); + it('returns null for a library reference, url, generator, or CSV-string values', () => { + const spec = { datasets: { ds: [{ c: 1 }] } }; + expect(rowsForDataBinding(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined + expect(rowsForDataBinding(spec, { url: 'x.csv' })).toBeNull(); + expect(rowsForDataBinding(spec, { sequence: { start: 0, stop: 5 } })).toBeNull(); + expect(rowsForDataBinding(spec, { values: 'a,b\n1,2' })).toBeNull(); + expect(rowsForDataBinding({ mark: 'bar' }, { values: [] })).toBeNull(); // empty rows }); }); diff --git a/src/core/spec-inline-data.ts b/src/core/spec-inline-data.ts index 77d0116..88ebd6c 100644 --- a/src/core/spec-inline-data.ts +++ b/src/core/spec-inline-data.ts @@ -1,20 +1,18 @@ /** * Inline data carried by a spec (docs/architecture/08 → editor augmentation). * - * Portable core. When a spec has no named library dataset, its columns still - * exist — inline, in `data.values` or a top-level `datasets` entry. This pulls - * those rows out so the editor can profile them (core/profile) and offer the same - * field hints a library-bound spec gets — a "ghost dataset" derived from the spec - * itself, with nothing stored. + * Portable core. When a view binds no named library dataset, its columns still + * exist — inline, in `data.values` or a self-defined top-level `datasets` entry. + * This pulls those rows out so the editor can profile them (core/profile) and offer + * the same field hints a library-bound view gets — a "ghost dataset" derived from + * the spec itself, with nothing stored. * - * Resolution order mirrors what the renderer sees first: the nearest `data.values` - * (top level, then any nested view — pruning the `data` payload from the walk like - * spec-refs does), then a top-level `datasets` entry. URL data has no rows to read - * statically, and `values` given as a CSV/TSV string needs format-aware parsing — - * both out of scope here. + * URL data has no rows to read statically, and `values` given as a CSV/TSV string + * needs format-aware parsing — both out of scope here (they yield null). */ import { isJsonObject } from './spec-config'; +import { classifyData, selfDefinedNames } from './spec-data'; /** An array of row objects, or null when the value is not tabular inline data. */ function asRows(value: unknown): Record[] | null { @@ -24,47 +22,23 @@ function asRows(value: unknown): Record[] | null { return null; } -/** Rows from a `data` object's `values`, or null. */ -function rowsFromData(data: unknown): Record[] | null { - return isJsonObject(data) ? asRows(data.values) : null; -} - -/** The first `data.values` reachable from `node`, top level before nested. */ -function firstDataValues(node: unknown): Record[] | null { - if (Array.isArray(node)) { - for (const item of node) { - const rows = firstDataValues(item); - if (rows) return rows; - } - return null; - } - if (isJsonObject(node)) { - const here = rowsFromData(node.data); - if (here) return here; - for (const key of Object.keys(node)) { - if (key === 'data') continue; // its `values` are payload, already taken above - const rows = firstDataValues(node[key]); - if (rows) return rows; - } - } - return null; -} - -/** The first non-empty table among the spec's top-level `datasets`, or null. */ -function firstNamedDataset(spec: Record): Record[] | null { - if (!isJsonObject(spec.datasets)) return null; - for (const value of Object.values(spec.datasets)) { - const rows = asRows(value); - if (rows) return rows; - } - return null; -} - /** - * The spec's inline data rows — `data.values` (top level or nested), else a - * top-level `datasets` entry — or null when the spec carries none. + * The tabular rows a *specific* `data` binding provides for static profiling, or + * null when it has none readable here: inline `data.values` (a JSON array of row + * objects), or a self-defined `{ name }` resolved against the spec's top-level + * `datasets`. A library `{ name }` reference (resolved from the dataset store), a + * `url`, a generator, or a CSV/TSV string `values` payload yields null — those + * carry no statically-readable JSON rows. Used by the view-scoped editor hints to + * profile the data bound at the cursor (`dataBindingAtPath` → this). */ -export function inlineDataRows(spec: unknown): Record[] | null { - if (!isJsonObject(spec)) return null; - return firstDataValues(spec) ?? firstNamedDataset(spec); +export function rowsForDataBinding(spec: unknown, data: unknown): Record[] | null { + const kind = classifyData(data); + if (kind === 'inline') return asRows((data as { values: unknown }).values); + if (kind === 'named') { + const name = (data as { name: string }).name; + if (selfDefinedNames(spec).has(name) && isJsonObject(spec) && isJsonObject(spec.datasets)) { + return asRows((spec.datasets as Record)[name]); + } + } + return null; }