Editor: Vega-Lite expression intelligence; single-home render errors

This commit is contained in:
2026-07-01 05:00:47 +03:00
parent 9b2618cac6
commit da6a675982
20 changed files with 1001 additions and 260 deletions
+41 -30
View File
@@ -1,10 +1,11 @@
/**
* LivePreview — busy overlay guard (spec §04; arch §10.2).
* LivePreview — busy overlay + render serialization (spec §04; arch §10.2).
*
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
* set that flag directly and assert the DOM result — no timing, no mocking of the
* async render path.
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
* `renderSpec` is mocked to park each embed in `H.pending` — a test decides when
* embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
* the busy overlay is driven through its real path — a render left in flight past
* the ~1s timer — not by poking a flag.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
@@ -12,7 +13,6 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { LivePreview } from './LivePreview';
@@ -71,7 +71,6 @@ beforeEach(() => {
H.pending.length = 0;
H.destroyed.length = 0;
H.configs.length = 0;
usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
@@ -84,11 +83,12 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount());
container.remove();
usePreviewStore.setState({ error: null, busy: false });
vi.clearAllMocks();
});
describe('LivePreview busy overlay', () => {
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
// bare [aria-hidden] query would also match decorative bits of the header
// controls (e.g. the chart-theme select's caret).
@@ -97,33 +97,44 @@ describe('LivePreview busy overlay', () => {
/rendering/i.test(el.textContent ?? ''),
) ?? null;
test('does not render the busy overlay when busy=false', () => {
// The overlay element should not be in the DOM at all during normal operation.
// Start a render and leave it parked in `H.pending`; advancing past the debounce
// and the ~1s busy timer flips `busy` on — the real (and only) path now that it
// is local state.
const startSlowRender = async () => {
act(() => {
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
};
test('no overlay and no aria-busy before a render is in flight', () => {
expect(overlay()).toBeNull();
});
test('renders the busy overlay when PreviewStore.busy=true', () => {
act(() => usePreviewStore.setState({ busy: true }));
expect(overlay()).not.toBeNull();
});
test('the preview body carries aria-busy=true when busy', () => {
act(() => usePreviewStore.setState({ busy: true }));
// The body element has aria-busy when the store says busy.
const busyEl = container.querySelector('[aria-busy="true"]');
expect(busyEl).not.toBeNull();
});
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
// aria-busy="false" is technically valid but needlessly verbose; we omit it.
expect(container.querySelector('[aria-busy]')).toBeNull();
});
test('overlay disappears when busy returns to false', () => {
act(() => usePreviewStore.setState({ busy: true }));
expect(overlay()).not.toBeNull();
act(() => usePreviewStore.setState({ busy: false }));
expect(overlay()).toBeNull();
test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
vi.useFakeTimers();
try {
await startSlowRender();
expect(overlay()).not.toBeNull();
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
test('the overlay clears once the render settles', async () => {
vi.useFakeTimers();
try {
await startSlowRender();
expect(overlay()).not.toBeNull();
act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
await tick(0);
expect(overlay()).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
+37 -26
View File
@@ -7,9 +7,10 @@
* mode applied) → renderSpec (vega-embed). A render-generation token guards
* against a slow render resolving after a newer one.
*
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
* are published to the shared PreviewStore so the editor pane mirrors them
* (§03E); the preview shows the same message in place of the chart.
* The pane header carries the Fit control (4 sizing modes, §04). A render or
* parse error shows here in place of the chart (error xor chart) — its single
* message home (arch 10 §1); the editor pinpoints the cause with a squiggle
* (§03E) rather than repeating the text.
*
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
* (M3) plugs into prepareSpecForRender without changing this component.
@@ -22,6 +23,7 @@ import type { Config } from 'vega-lite';
import { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { firstExpressionError } from '@core/spec-expressions';
import {
chartConfigForSelection,
chartThemeOptions,
@@ -33,7 +35,6 @@ import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
@@ -198,10 +199,11 @@ export function LivePreview() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError);
const busy = usePreviewStore((s) => s.busy);
const setBusy = usePreviewStore((s) => s.setBusy);
// Render status is local to this pane — it is both the only producer and the
// only consumer, so it needs no store (arch 01). `error` is the render/parse
// message (null = clean or blank); `busy` gates the >1s render overlay.
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
// export's image actions (which need the view) can enable/disable reactively —
// a ref change alone wouldn't re-render. Set true on a successful render, false
@@ -253,7 +255,7 @@ export function LivePreview() {
try {
parsed = JSON.parse(text);
} catch (e) {
if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`);
if (mine === generationRef.current) setError(`Invalid JSON · ${(e as Error).message}`);
return;
}
}
@@ -333,19 +335,25 @@ export function LivePreview() {
setChartReady(false);
setRenderEpoch((e) => e + 1);
clearBusy();
// A missing dataset reference is not a JSON/spec problem, so it gets a
// tailored, fixable message instead of the generic syntax hint (council:
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
// All render-failure messages share a line-led / noun-led terse shape
// (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
// malformed expression are attributed precisely — naming the fixable cause
// beats the generic "check your JSON" hint, which is wrong when the JSON is
// valid (council: GOV.UK error-message "be specific" + name the real fix).
if (e instanceof DatasetNotFoundError) {
setError(
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
`Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
);
} else {
setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
// A malformed Vega expression is located by line (the editor also
// squiggles it) and carries the same parser message the hover shows.
// Scanned over the untrimmed buffer so the line matches the editor's.
const exprError = firstExpressionError(shownText);
if (exprError) {
setError(`Line ${exprError.line} · ${exprError.message.replace(/\.$/, '')}`);
} else {
setError(`Render error · ${(e as Error).message}`);
}
}
}
}
@@ -434,16 +442,14 @@ export function LivePreview() {
return () => ro.disconnect();
}, []);
// Finalize the live view on unmount, and clear the shared error + busy state so
// stale transient state never outlives this pane.
// Finalize the live view on unmount so its timers/listeners don't outlive the
// pane. Render status is local state and dies with the component.
useEffect(
() => () => {
handleRef.current?.destroy();
handleRef.current = null;
setChartReady(false);
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
usePreviewStore.getState().setError(null);
usePreviewStore.getState().setBusy(false);
},
[],
);
@@ -474,10 +480,15 @@ export function LivePreview() {
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} />
</div>
{/* Visual only — no live region. The same error is announced once by the
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
so adding one here would double-announce it. */}
{error !== null && <pre className={styles.error}>{error}</pre>}
{/* The single home for a render/parse error (arch 10 §1): it sits where the
chart would be — error XOR chart — and is the lone live region, assertive
since the user just caused it. The editor pinpoints the spot via its squiggle,
so the message lives here, not duplicated under the editor. */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
{/*
* Busy overlay: non-blocking, overlays only the chart body, never the header
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
-17
View File
@@ -79,20 +79,3 @@
background: var(--bg);
pointer-events: none;
}
/* Inline render/parse error surface (spec §03E) — monospaced, distinct. */
.error {
flex: 0 0 auto;
max-height: 30%;
overflow: auto;
margin: 0;
padding: var(--space-3) var(--space-4);
border-top: var(--border-width) solid var(--support-error);
background: var(--layer-01);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--support-error);
white-space: pre-wrap;
word-break: break-word;
}
+17 -12
View File
@@ -9,8 +9,9 @@
*
* The pane header carries the Draft/Published toggle plus Publish and Revert
* (spec §03D). The published view is read-only — it shows the last published
* spec for reference; all editing happens on the draft. Render problems surface
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
* spec for reference; all editing happens on the draft. Render/parse problems
* surface in the preview pane (arch 10 §1); the editor marks the spot with an
* inline squiggle (spec §03E).
*/
import { useEffect, useMemo, useRef, type RefObject } from 'react';
@@ -45,6 +46,10 @@ import {
runWrapViews,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import {
configureSpecExpressionHints,
installExpressionMarkers,
} from '../services/spec-expression-hints';
import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
@@ -52,7 +57,6 @@ import { useDatasetStore } from '../stores/DatasetStore';
import { hasExtractableData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
@@ -167,6 +171,8 @@ configureJsonFormatter();
configureSpecTransformCodeActions();
// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
configureSpecDatasetHints();
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
configureSpecExpressionHints();
/** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space
@@ -390,7 +396,6 @@ export function SpecEditor() {
const uiTheme = useAppStore((s) => s.uiTheme);
const revealTarget = useAppStore((s) => s.revealTarget);
const composeRequest = useAppStore((s) => s.composeRequest);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
@@ -450,6 +455,11 @@ export function SpecEditor() {
// editor, because its commands need this editor's handle to apply the edit.
const codeLensSub = installSpecTransformCodeLens(editor);
// Validate Vega expressions in the draft and squiggle the invalid ones — per
// editor, because it writes markers to this model (docs/architecture/08).
// Debounced internally; recomputes on edit and on a draft↔published toggle.
const exprMarkersSub = installExpressionMarkers(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco
@@ -461,6 +471,7 @@ export function SpecEditor() {
configActionsSub.dispose();
transformActionsSub.dispose();
codeLensSub.dispose();
exprMarkersSub.dispose();
editor.dispose();
editorRef.current = null;
};
@@ -551,14 +562,8 @@ export function SpecEditor() {
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} />
</div>
{/* The single live region for render/parse errors: assertive, since the
user just caused it. The preview shows the same text visually but is
not a live region, so the message is announced once (doc §10.1). */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
{/* Render/parse errors surface in the preview pane (arch 10 §1), where the
chart would be; the editor pinpoints the spot with its squiggle. */}
</div>
);
}
+4 -24
View File
@@ -8,8 +8,8 @@
* 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.
* 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 `·<data-type>` 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,
@@ -25,8 +25,7 @@
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { validateExpression } from '@core/expr-validate';
import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
import { valueKeyAtOffset } from '@core/spec-cursor';
import { useSnippetStore } from '../stores/SnippetStore';
import {
availableFieldsAt,
@@ -38,8 +37,6 @@ import {
/** 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']);
/** Markdown hover for a field hint: type + stats for source, a note for derived. */
function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
@@ -109,25 +106,8 @@ export function configureSpecDatasetHints(): void {
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, 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);
+287
View File
@@ -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, []);
},
};
}
-54
View File
@@ -1,54 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { usePreviewStore } from './PreviewStore';
const store = () => usePreviewStore.getState();
afterEach(() => {
// Reset to a known clean state between tests so store leaks don't affect order.
store().setError(null);
store().setBusy(false);
});
describe('PreviewStore — error slice', () => {
it('starts with null error', () => {
expect(store().error).toBeNull();
});
it('setError stores the provided message', () => {
store().setError('Rendering error: something went wrong.');
expect(store().error).toBe('Rendering error: something went wrong.');
});
it('setError(null) clears the message', () => {
store().setError('an error');
store().setError(null);
expect(store().error).toBeNull();
});
});
describe('PreviewStore — busy slice', () => {
it('starts with busy=false', () => {
expect(store().busy).toBe(false);
});
it('setBusy(true) sets busy to true', () => {
store().setBusy(true);
expect(store().busy).toBe(true);
});
it('setBusy(false) clears busy', () => {
store().setBusy(true);
store().setBusy(false);
expect(store().busy).toBe(false);
});
it('busy and error are independent — setting one does not affect the other', () => {
store().setBusy(true);
store().setError('some error');
expect(store().busy).toBe(true);
expect(store().error).toBe('some error');
store().setBusy(false);
expect(store().error).toBe('some error'); // error unchanged by clearing busy
});
});
-43
View File
@@ -1,43 +0,0 @@
/**
* Preview render status — the bridge between the Live Preview (which owns
* rendering) and the two panes that surface its outcome.
*
* Both the editor and the preview must show the same render problem: spec §03E
* puts a readable error in the **editor** pane, and spec §04 puts one in the
* **preview** pane, for the very same failure (invalid JSON, or valid JSON that
* fails to render as Vega-Lite — including an unresolved dataset reference). The
* Live Preview is the single producer; it writes the current error here and both
* panes subscribe. `null` means the current spec rendered cleanly (or is blank).
*
* `busy` tracks whether a render is in flight long enough to warrant a visible
* indicator (arch §10.2: >~1s owes a non-blocking busy overlay). LivePreview arms
* a 1 s timer when a render starts and sets `busy = true` only if the render has
* not settled by then; it clears `busy` on settle or error regardless.
*
* Kept as its own tiny store rather than folded into the SnippetStore: this is
* transient render state, not durable domain data, and it must not be persisted.
*/
import { create } from 'zustand';
export interface PreviewState {
/** The current render error message, or null when the spec renders cleanly. */
error: string | null;
/** Set (or clear) the current render error. */
setError: (error: string | null) => void;
/**
* True while a render has been in flight for longer than the ~1s NN/g threshold
* (arch §10.2). The LivePreview overlay reads this to show a non-blocking busy
* indication; aria-busy on the preview region mirrors it.
*/
busy: boolean;
/** Set or clear the busy flag. */
setBusy: (busy: boolean) => void;
}
export const usePreviewStore = create<PreviewState>((set) => ({
error: null,
setError: (error) => set({ error }),
busy: false,
setBusy: (busy) => set({ busy }),
}));
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, referencedFields } from './expr-validate';
import { validateExpression, referencedFields, activeCall } from './expr-validate';
describe('validateExpression', () => {
it('accepts a well-formed Vega expression', () => {
@@ -37,3 +37,35 @@ describe('referencedFields', () => {
expect(referencedFields('datum.price *')).toEqual([]);
});
});
describe('activeCall', () => {
it('returns null when the cursor is not inside any call', () => {
expect(activeCall('datum.price + ')).toBeNull();
expect(activeCall('')).toBeNull();
});
it('names the enclosing call and reports the first argument', () => {
expect(activeCall('clamp(')).toEqual({ name: 'clamp', activeParam: 0 });
expect(activeCall('if(datum.x > 0')).toEqual({ name: 'if', activeParam: 0 });
});
it('counts commas to find the active argument', () => {
expect(activeCall('clamp(datum.x, 0, ')).toEqual({ name: 'clamp', activeParam: 2 });
});
it('reports the innermost call when calls are nested', () => {
expect(activeCall('if(datum.x > 0, min(1, ')).toEqual({ name: 'min', activeParam: 1 });
});
it('keeps a nested array argument on its outer call argument', () => {
expect(activeCall('clamp(datum.x, [1, 2')).toEqual({ name: 'clamp', activeParam: 1 });
});
it('does not count commas inside string literals', () => {
expect(activeCall("if(test(regexp('a,b'), datum.s), ")).toEqual({ name: 'if', activeParam: 1 });
});
it('treats a bare grouping paren as not a call', () => {
expect(activeCall('(datum.x + 1) * ')).toBeNull();
});
});
+57
View File
@@ -85,6 +85,63 @@ export function referencedFields(expr: string): string[] {
return fields;
}
/** The function call a cursor sits inside, and which argument it is on. */
export interface ActiveCall {
/** The called function's name (the identifier before the open paren). */
name: string;
/** Zero-based index of the argument the cursor is in (commas seen so far). */
activeParam: number;
}
/**
* Given the expression text from its start up to the cursor, the innermost
* function call the cursor sits inside — its name and the argument index — or
* `null` when the cursor is not within a call. Used to drive editor signature
* help. A forward scan keeps a stack of bracket frames (parens and square
* brackets), skips string literals, and counts commas per frame; the nearest
* unclosed frame whose open paren follows an identifier is the active call, and
* that frame's comma count is the active argument.
*/
export function activeCall(prefix: string): ActiveCall | null {
interface Frame {
name: string | null;
commas: number;
}
const stack: Frame[] = [];
for (let i = 0; i < prefix.length; i++) {
const c = prefix[i];
if (c === '\\') {
i++; // an escape consumes the next character
continue;
}
if (c === '"' || c === "'") {
// Skip a string literal so its parens/commas don't disturb the scan.
const quote = c;
i++;
while (i < prefix.length && prefix[i] !== quote) {
if (prefix[i] === '\\') i++;
i++;
}
continue;
}
if (c === '(') {
const name = /([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(prefix.slice(0, i));
stack.push({ name: name ? name[1] : null, commas: 0 });
} else if (c === '[') {
stack.push({ name: null, commas: 0 });
} else if (c === ')' || c === ']') {
stack.pop();
} else if (c === ',' && stack.length > 0) {
stack[stack.length - 1].commas++;
}
}
for (let i = stack.length - 1; i >= 0; i--) {
const frame = stack[i];
if (frame.name !== null) return { name: frame.name, activeParam: frame.commas };
}
return null;
}
/**
* The static field name of a `datum.<name>` / `datum['name']` member access, or
* `null` when the node isn't such an access (a different object, computed-dynamic
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { EXPRESSION_KEYS, expressionStringsIn, firstExpressionError } from './spec-expressions';
/** The substring a span points at, to assert the offset/length land on the text. */
function slice(text: string, span: { offset: number; length: number }): string {
return text.slice(span.offset, span.offset + span.length);
}
describe('spec-expressions', () => {
it('declares the expression-bearing keys', () => {
expect([...EXPRESSION_KEYS].sort()).toEqual(['calculate', 'expr', 'filter', 'test']);
});
it('finds a calculate expression and points between the quotes', () => {
const text = '{ "transform": [ { "calculate": "datum.a + 1", "as": "b" } ] }';
const spans = expressionStringsIn(text);
expect(spans).toHaveLength(1);
expect(spans[0].key).toBe('calculate');
expect(spans[0].value).toBe('datum.a + 1');
expect(slice(text, spans[0])).toBe('datum.a + 1');
});
it('finds string filter and test, in source order', () => {
const text =
'{ "transform": [ { "filter": "datum.x > 0" } ], "encoding": { "color": { "condition": { "test": "datum.x > 5", "value": "red" } } } }';
const spans = expressionStringsIn(text);
expect(spans.map((s) => s.value)).toEqual(['datum.x > 0', 'datum.x > 5']);
});
it('excludes object-form filter predicates (only string values are expressions)', () => {
const text =
'{ "transform": [ { "filter": { "field": "x", "gt": 0 } }, { "filter": { "param": "brush" } } ] }';
expect(expressionStringsIn(text)).toEqual([]);
});
it('finds an expr value (param / value-ref form)', () => {
const text = '{ "params": [ { "name": "n", "expr": "width / 2" } ] }';
const spans = expressionStringsIn(text);
expect(spans.map((s) => s.value)).toEqual(['width / 2']);
});
it('finds expressions across all views of a composition', () => {
const text =
'{ "hconcat": [ { "transform": [ { "calculate": "datum.a", "as": "a2" } ] }, { "transform": [ { "calculate": "datum.b", "as": "b2" } ] } ] }';
expect(expressionStringsIn(text).map((s) => s.value)).toEqual(['datum.a', 'datum.b']);
});
it('keeps offsets aligned when the string contains escapes', () => {
const text = '{ "transform": [ { "calculate": "datum[\\"a b\\"]", "as": "c" } ] }';
const spans = expressionStringsIn(text);
expect(spans).toHaveLength(1);
// The raw inner token (escapes intact) is what the span covers.
expect(slice(text, spans[0])).toBe('datum[\\"a b\\"]');
});
it('yields a zero-length span for an empty expression', () => {
const text = '{ "transform": [ { "calculate": "", "as": "c" } ] }';
const spans = expressionStringsIn(text);
expect(spans).toHaveLength(1);
expect(spans[0].length).toBe(0);
expect(spans[0].value).toBe('');
});
it('returns nothing for unparseable text rather than throwing', () => {
expect(expressionStringsIn('{ "transform": [ { "calc')).toEqual([]);
});
});
describe('firstExpressionError', () => {
it('returns null when every expression parses', () => {
const text = '{ "transform": [ { "filter": "datum.value > 0" } ] }';
expect(firstExpressionError(text)).toBeNull();
});
it('attributes a malformed expression to its property, with the parser message', () => {
const text = '{ "transform": [ { "filter": "datum[\'value\'] ==" } ] }';
const err = firstExpressionError(text);
expect(err?.key).toBe('filter');
expect(err?.expr).toBe("datum['value'] ==");
expect(err?.message).toBe('Unexpected end of input');
});
it('reports the 1-based line of the offending expression', () => {
const text = ['{', ' "transform": [', ' { "calculate": "clamp(" }', ' ]', '}'].join('\n');
expect(firstExpressionError(text)?.line).toBe(3);
});
it('ignores empty expressions (incomplete, not malformed)', () => {
expect(firstExpressionError('{ "transform": [ { "calculate": "", "as": "x" } ] }')).toBeNull();
});
it('returns the first malformed expression in document order', () => {
const text =
'{ "transform": [ { "calculate": "datum.a +", "as": "x" }, { "filter": "datum.b ==" } ] }';
expect(firstExpressionError(text)?.key).toBe('calculate');
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Expression strings within a spec document (docs/architecture/08 → editor
* augmentation). Portable core: `jsonc-parser`'s error-tolerant tree only — no
* browser APIs, no React, no Monaco — so it keeps working while the draft is
* briefly unparseable mid-edit.
*
* Vega-Lite embeds the Vega expression language inside a handful of JSON string
* values; the JSON schema sees them as opaque strings. This module finds every
* such string so the editor can validate them (→ markers) and so the completion /
* hover providers share one definition of "is the cursor in an expression?". The
* single-expression analysis itself lives in core/expr-validate; this module only
* *locates* the expressions in a document.
*/
import { parseTree, type Node } from 'jsonc-parser';
import { validateExpression } from './expr-validate';
/**
* Property keys whose **string** value is a Vega expression. `filter` and `test`
* also accept *object* predicates (`{field, gt}`, `{param}`) — those are not
* expressions and are excluded by the string-value check, not by this set.
*/
export const EXPRESSION_KEYS: ReadonlySet<string> = new Set([
'calculate',
'filter',
'expr',
'test',
]);
/** One expression string located in the document. */
export interface ExpressionSpan {
/** The property the expression sits under (`calculate`, `filter`, `expr`, `test`). */
key: string;
/** The expression text (the JSON string's unescaped value). */
value: string;
/** Byte offset of the first character *inside* the quotes. */
offset: number;
/** Length of the characters between the quotes (the raw token, escapes intact). */
length: number;
}
/** This property node's key + value when it is `<expr-key>: <string>`, else null. */
function expressionValue(node: Node): { key: string; valueNode: Node } | null {
if (node.type !== 'property' || !node.children) return null;
const [keyNode, valueNode] = node.children;
const key = typeof keyNode?.value === 'string' ? keyNode.value : undefined;
if (key === undefined || !EXPRESSION_KEYS.has(key)) return null;
return valueNode?.type === 'string' && typeof valueNode.value === 'string'
? { key, valueNode }
: null;
}
/**
* Every Vega expression string in the document, in source order. The span covers
* the characters *between* the quotes (so a marker squiggles the expression, not
* the JSON punctuation); an empty string yields a zero-length span the caller can
* ignore. Returns `[]` for unparseable text.
*/
export function expressionStringsIn(text: string): ExpressionSpan[] {
const tree = parseTree(text);
if (!tree) return [];
const spans: ExpressionSpan[] = [];
const walk = (node: Node): void => {
const found = expressionValue(node);
if (found) {
// Inner span = the quoted token minus its two quotes. Computed from the raw
// token length (not value.length) so escapes inside the string stay aligned.
spans.push({
key: found.key,
value: found.valueNode.value as string,
offset: found.valueNode.offset + 1,
length: Math.max(0, found.valueNode.length - 2),
});
}
if (node.children) for (const child of node.children) walk(child);
};
walk(tree);
return spans;
}
/** A syntactically-invalid expression located in the document. */
export interface ExpressionError {
/** The property it sits under (`filter`, `calculate`, …) — to name the problem. */
key: string;
/** The offending expression text. */
expr: string;
/** The parser's message — the same text the editor marker and hover show. */
message: string;
/** 1-based line of the expression, so the message can point at it (matches the
* editor's line numbers). */
line: number;
}
/** The 1-based line number of a byte offset in `text` (newlines counted before it). */
function lineAtOffset(text: string, offset: number): number {
let line = 1;
for (let i = 0; i < offset && i < text.length; i++) {
if (text[i] === '\n') line++;
}
return line;
}
/**
* The first syntactically-invalid expression in the document, or null when every
* expression parses. Lets a failed render attribute itself to a broken expression
* — a precise, located message that agrees with the editor's squiggle — instead of
* the generic "check your JSON" fallback (the JSON is valid; the expression isn't).
*/
export function firstExpressionError(text: string): ExpressionError | null {
for (const span of expressionStringsIn(text)) {
if (span.value.trim() === '') continue;
const result = validateExpression(span.value);
if (!result.valid) {
return {
key: span.key,
expr: span.value,
message: result.error ?? 'Invalid expression.',
line: lineAtOffset(text, span.offset),
};
}
}
return null;
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EXPR_CONSTANT_NAMES,
EXPR_FUNCTION_NAMES,
EXPR_SIGNATURES,
signatureLabel,
} from './vega-expr-catalog';
describe('vega-expr-catalog', () => {
it('derives a non-empty function set including staple names', () => {
expect(EXPR_FUNCTION_NAMES.length).toBeGreaterThan(20);
// Staples a user actually types — if the upstream registry shape changes and
// these vanish, fail loud rather than silently ship an empty completion list.
for (const name of ['if', 'datetime', 'test', 'clamp', 'lower', 'length']) {
expect(EXPR_FUNCTION_NAMES).toContain(name);
}
});
it('derives the constant set including PI and E', () => {
expect(EXPR_CONSTANT_NAMES).toContain('PI');
expect(EXPR_CONSTANT_NAMES).toContain('E');
});
it('returns names sorted', () => {
expect([...EXPR_FUNCTION_NAMES]).toEqual([...EXPR_FUNCTION_NAMES].sort());
expect([...EXPR_CONSTANT_NAMES]).toEqual([...EXPR_CONSTANT_NAMES].sort());
});
it('curates only functions the parser actually knows (curated ⊆ derived)', () => {
const derived = new Set(EXPR_FUNCTION_NAMES);
for (const name of Object.keys(EXPR_SIGNATURES)) {
expect(derived.has(name), `${name} is curated but not a real parser function`).toBe(true);
}
});
it('keys each signature by its own name', () => {
for (const [key, sig] of Object.entries(EXPR_SIGNATURES)) {
expect(sig.name).toBe(key);
}
});
it('formats a fixed-arity signature label', () => {
expect(signatureLabel(EXPR_SIGNATURES.if)).toBe('if(test, thenValue, elseValue)');
});
it('marks the repeating parameter of a variadic signature', () => {
expect(signatureLabel(EXPR_SIGNATURES.min)).toBe('min(...values)');
});
it('formats a no-argument signature', () => {
expect(signatureLabel(EXPR_SIGNATURES.now)).toBe('now()');
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Vega expression catalog — the function and constant names the expression
* language exposes, for editor completion / signature help inside `calculate` /
* `filter` / `expr` / `test` strings (docs/architecture/08 → editor augmentation).
*
* Portable core: no browser APIs, no React, no Monaco. The **name set is derived**,
* not hand-listed — `vega-expression` (already imported by core/expr-validate, no
* added bundle weight) ships the parser's own function/constant tables, so the
* catalog never drifts from what the parser actually accepts. We add only the one
* thing the registry lacks: a **curated signature** (parameter names + a one-line
* doc) for the commonly-typed functions, used by hover and signature help. The
* curated set is a subset of the derived names — a doc for a function the parser
* doesn't know would be a lie; `vega-expr-catalog.test.ts` asserts the inclusion.
*
* Out of scope (deliberate): the *fuller* runtime function set Vega-Lite registers
* via `vega-functions` (`toNumber`, `format`, `indexof`, …). That package exports
* implementations, not a clean name registry, so listing it cleanly is more work
* than it earns here; the base parser set below covers the overwhelming majority of
* what users write (math, dates, strings, `if`, `datetime`, `test`).
*/
// TODO: extend the name set with vega-functions' runtime registry (toNumber,
// format, indexof, isValid, inrange, …) if completion coverage proves too thin.
import { functions, constants } from 'vega-expression';
/** A curated call signature for one function — what the registry can't tell us. */
export interface FunctionSignature {
/** The function name (always one the parser knows — see the inclusion test). */
name: string;
/** Ordered parameter names, e.g. `['test', 'thenValue', 'elseValue']`. */
params: readonly string[];
/** True when the last parameter repeats, e.g. `min(...values)`. */
variadic?: boolean;
/** A one-line description for hover and the signature-help label. */
doc: string;
}
/**
* Every function name the expression parser exposes, sorted — derived from the
* parser's own table. `functions(codegen)` maps each name to codegen output; we
* pass an empty codegen because only the **keys** (the names) are needed, and they
* are independent of the generator.
*/
export const EXPR_FUNCTION_NAMES: readonly string[] = Object.keys(functions({})).sort();
/** Every constant name the parser exposes (`PI`, `E`, `NaN`, …), sorted. */
export const EXPR_CONSTANT_NAMES: readonly string[] = Object.keys(constants).sort();
/**
* Curated signatures for the commonly-typed functions, keyed by name. A subset of
* `EXPR_FUNCTION_NAMES`; functions absent here still complete as bare names, just
* without parameter hints.
*/
export const EXPR_SIGNATURES: Readonly<Record<string, FunctionSignature>> = Object.freeze(
Object.fromEntries(
(
[
{
name: 'if',
params: ['test', 'thenValue', 'elseValue'],
doc: 'Conditional: thenValue when test is truthy, else elseValue.',
},
{ name: 'isNaN', params: ['value'], doc: 'True when value is NaN.' },
{ name: 'isFinite', params: ['value'], doc: 'True when value is a finite number.' },
{ name: 'abs', params: ['value'], doc: 'Absolute value.' },
{ name: 'ceil', params: ['value'], doc: 'Round up to the nearest integer.' },
{ name: 'floor', params: ['value'], doc: 'Round down to the nearest integer.' },
{ name: 'round', params: ['value'], doc: 'Round to the nearest integer.' },
{ name: 'sqrt', params: ['value'], doc: 'Square root.' },
{ name: 'pow', params: ['base', 'exponent'], doc: 'base raised to exponent.' },
{ name: 'min', params: ['values'], variadic: true, doc: 'Smallest of the arguments.' },
{ name: 'max', params: ['values'], variadic: true, doc: 'Largest of the arguments.' },
{
name: 'clamp',
params: ['value', 'min', 'max'],
doc: 'Restrict value to the [min, max] range.',
},
{ name: 'random', params: [], doc: 'A pseudo-random number in [0, 1).' },
{
name: 'parseFloat',
params: ['string'],
doc: 'Parse a floating-point number from a string.',
},
{
name: 'parseInt',
params: ['string', 'base'],
doc: 'Parse an integer from a string (optional radix).',
},
{ name: 'length', params: ['value'], doc: 'Length of an array or string.' },
{ name: 'lower', params: ['string'], doc: 'Lower-cased string.' },
{ name: 'upper', params: ['string'], doc: 'Upper-cased string.' },
{
name: 'substring',
params: ['string', 'start', 'end'],
doc: 'Substring from start up to (optional) end.',
},
{
name: 'split',
params: ['string', 'separator', 'limit'],
doc: 'Split a string into an array on separator.',
},
{
name: 'trim',
params: ['string'],
doc: 'String with leading/trailing whitespace removed.',
},
{
name: 'regexp',
params: ['pattern', 'flags'],
doc: 'Build a RegExp from a pattern (optional flags).',
},
{
name: 'test',
params: ['regexp', 'string'],
doc: 'True when the RegExp matches the string.',
},
{ name: 'now', params: [], doc: 'Current timestamp (ms since the Unix epoch).' },
{
name: 'datetime',
params: ['year', 'month', 'day', 'hours', 'minutes', 'seconds', 'milliseconds'],
doc: 'A Date from local-time components (month is 0-based).',
},
{ name: 'time', params: ['datetime'], doc: 'Timestamp (ms) of a date value.' },
{ name: 'year', params: ['datetime'], doc: 'Year of a date value (local time).' },
{
name: 'month',
params: ['datetime'],
doc: 'Month of a date value, 0-based (local time).',
},
{
name: 'date',
params: ['datetime'],
doc: 'Day of the month of a date value (local time).',
},
{
name: 'day',
params: ['datetime'],
doc: 'Day of the week of a date value, 0 = Sunday (local time).',
},
{ name: 'hours', params: ['datetime'], doc: 'Hours of a date value (local time).' },
{ name: 'minutes', params: ['datetime'], doc: 'Minutes of a date value (local time).' },
{ name: 'seconds', params: ['datetime'], doc: 'Seconds of a date value (local time).' },
] satisfies FunctionSignature[]
).map((sig) => [sig.name, Object.freeze(sig)]),
),
);
/**
* The human-readable call signature, e.g. `if(test, thenValue, elseValue)` or
* `min(...values)`. Used as the completion detail and the signature-help label.
*/
export function signatureLabel(sig: FunctionSignature): string {
const params = sig.params.map((p, i) =>
sig.variadic && i === sig.params.length - 1 ? `...${p}` : p,
);
return `${sig.name}(${params.join(', ')})`;
}