mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
/**
|
|
* 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, []);
|
|
},
|
|
};
|
|
}
|