mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: Vega-Lite expression intelligence; single-home render errors
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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.<partial>` — completing a field by dot access. */
|
||||
const DATUM_DOT = /datum\.([A-Za-z0-9_$]*)$/;
|
||||
/** Cursor sitting in `datum['<partial>` — 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<typeof setTimeout> | 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, []);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user