/** * Dataset-aware editor hints (docs/architecture/08 → editor augmentation) — the * three things the Vega-Lite JSON schema *can't* know, because they depend on the * user's data and this spec: * * - **Completion** — in a `field` / `groupby` value, the bound dataset's real * column names (plus the spec's derived fields). The schema only knows `field` * takes a string; it can't list your columns. Enum values (`type`, `mark`, …) * are left to the schema — we add only what it lacks, no second source. * - **Hover** — a column's inferred type + cardinality/range (from the stored * profile). Monaco merges this with the schema's own hovers. (Expression-string * hovers and completion live in services/spec-expression-hints.) * - **Inlay hints** — a faint `·` beside each `field` (the column's * raw type: number/string/date/boolean), annotation without touching the text. * Deliberately the *data* type, not the encoding `type` — they share the line, * so a `: quantitative` here would read as annotating the adjacent `"type"`, * which the hint never describes. * * All three read the active draft's bound dataset (services/active-dataset) and * the cursor's JSON context (core/spec-cursor) at provide-time via `getState()` — * outside React. They register **once, globally for JSON** (like the schema and * formatter), not per editor. Suggestion-only: over- or under-listing is * harmless, which is why there is deliberately no "unknown field" diagnostic * (that would false-positive on every data-dependent derived column). */ import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; import { valueKeyAtOffset } from '@core/spec-cursor'; import { useSnippetStore } from '../stores/SnippetStore'; 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']); /** Markdown hover for a field hint: type + stats for source, a note for derived. */ function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] { if (hint.derived || hint.type === null) { return [{ value: `**${hint.name}** · _derived by a transform_` }]; } // The column's raw data type (number/string/date/boolean) — same vocabulary as // the inlay hint, not the Vega-Lite encoding `type` the user declares. const lines = [`**${hint.name}** · \`${hint.type}\``]; const stat = info.columnStats.find((s) => s.name === hint.name); if (stat) { if (stat.numericExtent) lines.push(`Range ${stat.numericExtent.min} – ${stat.numericExtent.max}`); lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`); } lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the spec’s inline data_'); return lines.map((value) => ({ value })); } let registered = false; /** Register the dataset-aware completion / hover / inlay providers once. */ export function configureSpecDatasetHints(): void { if (registered) return; registered = true; monaco.languages.registerCompletionItemProvider('json', { triggerCharacters: ['"'], provideCompletionItems(model, position) { // Field suggestions only edit the draft; nothing to offer on the published view. if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] }; 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 = availableFieldsAt(text, offset); if (fields.length === 0) return { suggestions: [] }; const word = model.getWordUntilPosition(position); const range = new monaco.Range( position.lineNumber, word.startColumn, position.lineNumber, word.endColumn, ); return { suggestions: fields.map((f) => ({ label: f.name, kind: f.derived ? monaco.languages.CompletionItemKind.Variable : monaco.languages.CompletionItemKind.Field, detail: f.derived || f.type === null ? 'derived field' : f.type, insertText: f.name, range, })), }; }, }); // All three providers annotate the draft only: they derive their field set from // 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', { provideHover(model, position) { if (useSnippetStore.getState().editorView !== 'draft') return null; const text = model.getValue(); const offset = model.getOffsetAt(position); // Over a field name: its type + stats, resolved at this view's data binding. // (Expression-string hovers live in services/spec-expression-hints.) const word = model.getWordAtPosition(position); if (word) { const info = dataInfoAt(text, offset); const hint = info.fields.find((f) => f.name === word.word); if (hint) { return { range: new monaco.Range( position.lineNumber, word.startColumn, position.lineNumber, word.endColumn, ), contents: fieldHoverContents(hint, info), }; } } return null; }, }); monaco.languages.registerInlayHintsProvider('json', { provideInlayHints(model, range) { if (useSnippetStore.getState().editorView !== 'draft') 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; // 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 }, // The column's raw data type (number/string/date/boolean), not the // Vega-Lite encoding `type`: a `: quantitative` here would read as an // annotation of the adjacent `"type"`, which the hint never describes. // The leading `·` marks it as a data fact about the field, not JSON syntax. label: `·${type}`, kind: monaco.languages.InlayHintKind.Type, paddingLeft: true, }); } return { hints, dispose() {} }; }, }); }