/** * 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); over a `calculate`/`filter`/`expr` string, a live validity check * via core/expr-validate. Monaco merges these with the schema's own hovers. * - **Inlay hints** — a faint `: ` beside each `field`, annotation without * touching the text. * * 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 { defaultFieldType } from '@core/chart-builder'; 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'; /** Property values that reference a data field (where column names belong). */ const FIELD_KEYS = new Set(['field', 'groupby']); /** Property values that hold a Vega expression (where the validity check fires). */ const EXPR_KEYS = new Set(['calculate', 'filter', 'expr']); /** A short type label for a source field; null type (derived) is shown elsewhere. */ const typeLabel = (type: ColumnType): string => defaultFieldType(type); /** 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_` }]; } const lines = [`**${hint.name}** · \`${typeLabel(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 key = valueKeyAtOffset(model.getValue(), model.getOffsetAt(position)); if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] }; const fields = availableFields(); 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' : typeLabel(f.type), insertText: f.name, range, })), }; }, }); // 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 // 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 an expression value: a live validity check. const key = valueKeyAtOffset(text, offset); if (key !== null && EXPR_KEYS.has(key)) { const expr = stringValueAtOffset(text, offset); if (expr !== null) { const result = validateExpression(expr); return { contents: [ { value: result.valid ? '✓ Valid Vega expression' : `✗ ${result.error ?? 'Invalid expression'}`, }, ], }; } } // Over a field name: its type + stats. const word = model.getWordAtPosition(position); if (word) { const info = dataInfo(); 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 types = new Map(availableFields().map((f) => [f.name, f.type])); if (types.size === 0) return { hints: [], dispose() {} }; 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]); if (!type) continue; // unknown or derived (no type to annotate) hints.push({ position: { lineNumber: line, column: match.index + match[0].length + 1 }, label: `: ${typeLabel(type)}`, kind: monaco.languages.InlayHintKind.Type, paddingLeft: true, }); } return { hints, dispose() {} }; }, }); }