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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()');
|
||||
});
|
||||
});
|
||||
@@ -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(', ')})`;
|
||||
}
|
||||
Reference in New Issue
Block a user