/** * Expression-aware editor intelligence (docs/architecture/08 → editor * augmentation) — the help the Vega-Lite JSON schema *structurally can't* give, * because the expression language lives inside opaque JSON strings (`calculate`, * `filter`, `expr`, `test`): * * - **Completion** — after `datum.` / `datum['…'`, the bound view's real column * names; otherwise the expression language's functions (`if`, `datetime`, …) * and constants (`PI`, `E`), names derived from the parser itself * (core/vega-expr-catalog). * - **Signature help** — parameter hints for the curated common functions, with * the active argument tracked as you type past each comma. * - **Hover** — over an expression string, a live validity check * (core/expr-validate, the same parser the chart uses). * - **Markers** — every expression string is parsed; a malformed one squiggles * in place. This is the app's only editor-marker source besides the JSON * worker, so it owns a distinct marker namespace (`vega-expr`). * * The three language providers register **once, globally for JSON** (like the * schema, formatter, and dataset hints); the marker pass is **per editor** * (it writes to a specific model and is torn down with it). All read the draft * buffer and are gated to the draft view — the published view is a read-only * reference, where expression authoring help is marginal. */ import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; import { activeCall, validateExpression } from '@core/expr-validate'; import { valueKeyAtOffset, stringValueAtOffset } from '@core/spec-cursor'; import { EXPRESSION_KEYS, expressionStringsIn } from '@core/spec-expressions'; import { EXPR_CONSTANT_NAMES, EXPR_FUNCTION_NAMES, EXPR_SIGNATURES, signatureLabel, } from '@core/vega-expr-catalog'; import { availableFieldsAt, type FieldHint } from './active-dataset'; import { useSnippetStore } from '../stores/SnippetStore'; /** A name that can follow `datum.`; others (with spaces, etc.) need bracket access. */ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** Cursor sitting in `datum.` — completing a field by dot access. */ const DATUM_DOT = /datum\.([A-Za-z0-9_$]*)$/; /** Cursor sitting in `datum['` — completing a field by bracket access. */ const DATUM_BRACKET = /datum\[\s*['"]([^'"]*)$/; /** Debounce for the marker recompute — responsive without thrashing on every key. */ const MARKER_DEBOUNCE_MS = 300; /** Marker namespace, kept distinct from the JSON worker's own markers. */ const MARKER_OWNER = 'vega-expr'; /** * The expression text from the start of the cursor's expression string up to the * cursor, or null when the cursor is not inside one (or not on the draft view). * Drives both completion ("what am I typing?") and signature help ("which call am * I in?"). The containing string is located via the core enumerator so the prefix * excludes the JSON `"key": "` framing. */ function expressionPrefixAt( model: monaco.editor.ITextModel, position: monaco.Position, ): string | null { if (useSnippetStore.getState().editorView !== 'draft') return null; const text = model.getValue(); const offset = model.getOffsetAt(position); const key = valueKeyAtOffset(text, offset); if (key === null || !EXPRESSION_KEYS.has(key)) return null; for (const span of expressionStringsIn(text)) { if (offset >= span.offset && offset <= span.offset + span.length) { return text.slice(span.offset, offset); } } return null; } /** A completion item for a data field (a real column or a transform-derived one). */ function fieldItem(field: FieldHint, range: monaco.IRange): monaco.languages.CompletionItem { return { label: field.name, kind: field.derived || field.type === null ? monaco.languages.CompletionItemKind.Variable : monaco.languages.CompletionItemKind.Field, detail: field.derived || field.type === null ? 'derived field' : field.type, insertText: field.name, range, }; } /** A completion item for an expression function — curated signature as the detail. */ function functionItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem { const sig = EXPR_SIGNATURES[name]; const noArgs = sig !== undefined && sig.params.length === 0; return { label: name, kind: monaco.languages.CompletionItemKind.Function, detail: sig ? signatureLabel(sig) : undefined, documentation: sig ? { value: sig.doc } : undefined, // Land the cursor inside the parens (snippet `$1`), unless the function takes // no arguments — then close the call outright. insertText: noArgs ? `${name}()` : `${name}($1)`, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, range, }; } /** A completion item for an expression constant (`PI`, `E`, …). */ function constantItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem { return { label: name, kind: monaco.languages.CompletionItemKind.Constant, insertText: name, range, }; } let registered = false; /** Register the expression completion / signature-help / hover providers once. */ export function configureSpecExpressionHints(): void { if (registered) return; registered = true; monaco.languages.registerCompletionItemProvider('json', { // `.` opens field completion after `datum`; the quote characters open it inside // `datum['…']`; quick-suggest (strings:true) covers the function-name case. triggerCharacters: ['.', '"', "'"], provideCompletionItems(model, position) { const prefix = expressionPrefixAt(model, position); if (prefix === null) return { suggestions: [] }; // Build the replace range from the partial WE parse out of the prefix, never // from Monaco's JSON word: that language's wordPattern treats `.` and `(` as // word characters, so getWordUntilPosition after `datum.` (or `fn(`) returns // the whole `datum.`/`fn(` token — which would both mis-target the edit and // filter every suggestion out (none start with `datum.`). const replaceRange = (partialLength: number): monaco.Range => new monaco.Range( position.lineNumber, position.column - partialLength, position.lineNumber, position.column, ); const dot = DATUM_DOT.exec(prefix); const bracket = dot ? null : DATUM_BRACKET.exec(prefix); if (dot || bracket) { const fields = availableFieldsAt(model.getValue(), model.getOffsetAt(position)); if (fields.length === 0) return { suggestions: [] }; // The partial typed after `datum.` / `datum['` — replace only that, leaving // the `datum` token before it intact. const partial = dot ? dot[1] : bracket![1]; const range = replaceRange(partial.length); // Only identifier-safe names are usable after a dot; brackets take any name. const candidates = dot ? fields.filter((f) => IDENTIFIER.test(f.name)) : fields; return { suggestions: candidates.map((f) => fieldItem(f, range)) }; } // Function / constant context: the partial is the trailing identifier (Monaco's // JSON word would reach back across a preceding `(` and break filtering). const ident = /[A-Za-z_$][A-Za-z0-9_$]*$/.exec(prefix); const range = replaceRange(ident ? ident[0].length : 0); const suggestions: monaco.languages.CompletionItem[] = [ ...EXPR_FUNCTION_NAMES.map((name) => functionItem(name, range)), ...EXPR_CONSTANT_NAMES.map((name) => constantItem(name, range)), { label: 'datum', kind: monaco.languages.CompletionItemKind.Keyword, detail: 'the current data record', insertText: 'datum', range, }, ]; return { suggestions }; }, }); monaco.languages.registerSignatureHelpProvider('json', { signatureHelpTriggerCharacters: ['(', ','], signatureHelpRetriggerCharacters: [','], provideSignatureHelp(model, position) { const prefix = expressionPrefixAt(model, position); if (prefix === null) return null; const call = activeCall(prefix); if (!call) return null; const sig = EXPR_SIGNATURES[call.name]; if (!sig || sig.params.length === 0) return null; const info: monaco.languages.SignatureInformation = { label: signatureLabel(sig), documentation: { value: sig.doc }, parameters: sig.params.map((p, i) => ({ // The label must be a substring of the signature label so Monaco can // highlight the active parameter — `...rest` for the variadic tail. label: sig.variadic && i === sig.params.length - 1 ? `...${p}` : p, })), }; // A variadic tail keeps highlighting its last parameter past the final comma. const activeParameter = sig.variadic ? Math.min(call.activeParam, sig.params.length - 1) : call.activeParam; return { value: { signatures: [info], activeSignature: 0, activeParameter }, dispose() {} }; }, }); monaco.languages.registerHoverProvider('json', { provideHover(model, position) { if (useSnippetStore.getState().editorView !== 'draft') return null; const text = model.getValue(); const offset = model.getOffsetAt(position); const key = valueKeyAtOffset(text, offset); if (key === null || !EXPRESSION_KEYS.has(key)) return null; const expr = stringValueAtOffset(text, offset); if (expr === null) return null; const result = validateExpression(expr); return { contents: [ { value: result.valid ? '✓ Valid Vega expression' : `✗ ${result.error ?? 'Invalid expression'}`, }, ], }; }, }); } /** * Validate every expression string in this editor's model and squiggle the invalid * ones (per editor — it writes markers to one model). Recomputes debounced on edit, * and on a draft↔published toggle so the draft-only gate is honored even when the * two buffers are identical. Disposed with the editor; clears its markers on the * way out. */ export function installExpressionMarkers( editor: monaco.editor.IStandaloneCodeEditor, ): monaco.IDisposable { let timer: ReturnType | null = null; const recompute = (): void => { const model = editor.getModel(); if (!model) return; // The published view is a read-only reference — no authoring markers there. if (useSnippetStore.getState().editorView !== 'draft') { monaco.editor.setModelMarkers(model, MARKER_OWNER, []); return; } const text = model.getValue(); const markers: monaco.editor.IMarkerData[] = []; for (const span of expressionStringsIn(text)) { if (span.length === 0) continue; // an empty expression isn't an error const result = validateExpression(span.value); if (result.valid) continue; const start = model.getPositionAt(span.offset); const end = model.getPositionAt(span.offset + span.length); markers.push({ severity: monaco.MarkerSeverity.Error, message: result.error ?? 'Invalid Vega expression.', startLineNumber: start.lineNumber, startColumn: start.column, endLineNumber: end.lineNumber, endColumn: end.column, }); } monaco.editor.setModelMarkers(model, MARKER_OWNER, markers); }; const schedule = (): void => { if (timer) clearTimeout(timer); timer = setTimeout(recompute, MARKER_DEBOUNCE_MS); }; const contentSub = editor.onDidChangeModelContent(schedule); const viewSub = useSnippetStore.subscribe((s, prev) => { if (s.editorView !== prev.editorView) recompute(); }); recompute(); // initial pass return { dispose() { if (timer) clearTimeout(timer); contentSub.dispose(); viewSub(); const model = editor.getModel(); if (model) monaco.editor.setModelMarkers(model, MARKER_OWNER, []); }, }; }