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
+9 -2
View File
@@ -12,7 +12,7 @@ project's spec (`docs/spec/`) and architecture playbook (`docs/architecture/`).
This skill is also executed by a **clean-context subagent** at session wrap-up (see This skill is also executed by a **clean-context subagent** at session wrap-up (see
CLAUDE.md → Session wrap-up protocol). When running as that subagent: you deliberately CLAUDE.md → Session wrap-up protocol). When running as that subagent: you deliberately
have no session context — judge the diff against the written contracts only, and return have no session context — judge the diff against the written contracts only, and return
the summary (rule #15) as your final message so the session agent can relay it. If a the summary (rule #18) as your final message so the session agent can relay it. If a
change looks deliberate but its rationale is recorded nowhere, that absence is itself a change looks deliberate but its rationale is recorded nowhere, that absence is itself a
finding. finding.
@@ -178,9 +178,16 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
(whole-spec drag/simplify) — never re-inlining the model/parse/writeBack prologue. The (whole-spec drag/simplify) — never re-inlining the model/parse/writeBack prologue. The
family has grown by copy-paste twice (eng-council; arch 08). family has grown by copy-paste twice (eng-council; arch 08).
17. **Spec tracks the surfaces it mandates** (`docs/spec/`): a diff that **removes, moves, or
renames a user-facing surface** — where a message, control, or affordance lives — updates the
`docs/spec/` section describing it, not only the `docs/architecture/` pattern doc. The spec is
the behavioral contract; an arch-doc-only update leaves it describing a surface that no longer
exists. An arch-only update once left spec §03E mandating an editor-pane error message after it
had moved to the preview (eng-council).
### Output ### Output
17. **Summary**: respond with a summary of changes — choices made due to these instructions, 18. **Summary**: respond with a summary of changes — choices made due to these instructions,
choices where multiple approaches existed, and non-obvious architectural assumptions the choices where multiple approaches existed, and non-obvious architectural assumptions the
user should know but might not spot in the diff. If the summary mentions an observation you user should know but might not spot in the diff. If the summary mentions an observation you
chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site. chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site.
+7
View File
@@ -236,6 +236,13 @@ feature owns — which modal is open, the runtime theme, transient render flags.
> that's the signal to extract a feature store. A bloated central store is the > that's the signal to extract a feature store. A bloated central store is the
> thing this split exists to prevent. > thing this split exists to prevent.
> Rule: a store earns its place by **decoupling** producers from consumers — a fact
> belongs in one when more than one component reads it, or when many sites produce it for
> one surface to consume (the imperative `notify()` / `confirm()` overlay stores). When a
> single component is both the only producer and the only consumer, the fact is that
> component's **local `useState`**, not a store — a store there decouples nothing, and is
> the shape to fold back.
--- ---
## 4. Actions: Mutations Live in the Store, Not Components ## 4. Actions: Mutations Live in the Store, Not Components
@@ -538,11 +538,12 @@ blank mid-edit.
### A second preview surface: the Chart Builder ### A second preview surface: the Chart Builder
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore` The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The (shown spec) and `AppStore` (fit mode/theme), and keeps its render status (`error`/`busy`) in
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it its own local state. The **Chart Builder modal** needs a preview of a _different_ spec source
does **not** reuse `LivePreview`; it runs its own small debounced render over the same (its config), so it does **not** reuse `LivePreview`; it runs its own small debounced render
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the over the same `chart-renderer.renderSpec` + `prepareSpecForRender`, with its own **local**
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one error state. Each preview surface owns its render status locally — no shared store to
cross-talk. Two preview surfaces, one
renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore` renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore`
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview (config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
component only if a _third_ surface appears. component only if a _third_ surface appears.
@@ -603,13 +604,13 @@ the existing view is re-measured via a `ResizeObserver`-driven event — see §8
A spec that cannot be rendered must produce a **readable** message in the preview A spec that cannot be rendered must produce a **readable** message in the preview
area and recover on its own once the spec is valid again. Errors arise at three area and recover on its own once the spec is valid again. Errors arise at three
stages, all funneled to one error field the preview reads: stages, all funneled to the one error state the preview owns:
| Stage | Failure | Surfaced as | | Stage | Failure | Surfaced as |
| -------------------------------- | -------------------------------------- | ---------------------- | | -------------------------------- | -------------------------------------- | --------------------------------- |
| Parse | Invalid JSON | "Invalid JSON: …" | | Parse | Invalid JSON | `Invalid JSON · …` |
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" | | Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | `Dataset "x" not found · …` |
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" | | Embed (`vega-embed`) | Vega-Lite compile, or a bad expression | `Line N · …` / `Render error · …` |
```ts ```ts
// inside render(), driven by the debounced renderer // inside render(), driven by the debounced renderer
@@ -620,7 +621,7 @@ async function render(): Promise<void> {
if (!text) { if (!text) {
current?.destroy(); current?.destroy();
current = null; current = null;
usePreviewStore.getState().setError(null); setError(null); // `error`/`busy` are the pane's local state, not a store
return; return;
} }
@@ -628,7 +629,7 @@ async function render(): Promise<void> {
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch (e) { } catch (e) {
usePreviewStore.getState().setError(`Invalid JSON: ${(e as Error).message}`); setError(`Invalid JSON · ${(e as Error).message}`);
return; // keep the last good chart underneath the error, or show the message return; // keep the last good chart underneath the error, or show the message
} }
@@ -638,14 +639,9 @@ async function render(): Promise<void> {
const config = chartConfigFor(uiTheme); const config = chartConfigFor(uiTheme);
current?.destroy(); current?.destroy();
current = await renderSpec(node, prepared, config); current = await renderSpec(node, prepared, config);
usePreviewStore.getState().setError(null); // success clears any prior error setError(null); // success clears any prior error
} catch (e) { } catch (e) {
usePreviewStore setError(`Render error · ${(e as Error).message}`);
.getState()
.setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
} }
} }
``` ```
@@ -660,10 +656,10 @@ manual retry, no reload.
- **Do** treat empty/blank spec text as "render nothing" — finalize the current - **Do** treat empty/blank spec text as "render nothing" — finalize the current
view, clear the error, show a clean empty pane. view, clear the error, show a clean empty pane.
- **Do** clear the error state on every successful render. - **Do** clear the error state on every successful render.
- **Do** make messages legible and actionable (the underlying reason plus a hint - **Do** make messages legible and actionable — lead with the location or the failing
to check JSON/Vega-Lite validity), never a raw stack trace dump. stage, then the underlying reason — never a raw stack trace dump.
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset - **Do** distinguish the failing stage in the message (invalid JSON vs missing dataset
not found vs Rendering error). vs a bad expression or render error).
- **Don't** show a broken/partial chart — replace the chart area with the - **Don't** show a broken/partial chart — replace the chart area with the
message. message.
- **Don't** require a manual "retry"; validity restores the chart on its own. - **Don't** require a manual "retry"; validity restores the chart on its own.
@@ -729,5 +725,5 @@ bookkeeping. Gate the observer to responsive modes (Original needs no re-fit).
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` | | Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
| Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) | | Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) |
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) | | Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` | | Errors | One error field, cleared on success, empty = nothing | `LivePreview` local state (`error`/`busy`) |
| Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) | | Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |
+34 -7
View File
@@ -277,10 +277,10 @@ defaults-spread" discipline is worth keeping.
## 5 · Editor augmentation (our layer over the borrowed base) ## 5 · Editor augmentation (our layer over the borrowed base)
Beyond schema validation/completion (§1), the spec editor adds structural refactors and Beyond schema validation/completion (§1), the spec editor adds structural refactors,
data-aware hints — the edits that are awkward in raw JSON and out of reach of the data-aware hints, and expression intelligence — the edits and feedback that are awkward in
single-view visual builder. All transform logic is pure `src/core/`; the Monaco glue is raw JSON and out of reach of the single-view visual builder. All transform and analysis
thin app-layer services. logic is pure `src/core/`; the Monaco glue is thin app-layer services.
**Core (pure, portable):** **Core (pure, portable):**
@@ -321,11 +321,25 @@ thin app-layer services.
flatten/collapse cleanup runs after. `simplifyStructure` collapses redundant single-child flatten/collapse cleanup runs after. `simplifyStructure` collapses redundant single-child
compositions recursively (a `{hconcat:[v]}` is just `v`; `facet`/`repeat` hold one child by design compositions recursively (a `{hconcat:[v]}` is just `v`; `facet`/`repeat` hold one child by design
and are left alone) — the wireframe's Simplify, returning null when nothing is redundant. and are left alone) — the wireframe's Simplify, returning null when nothing is redundant.
- `expr-validate` — one Vega expression, parsed with Vega's own `parseExpression` (no divergent
grammar): `validateExpression` (valid + parser message), `referencedFields` (its `datum.<field>`
references), and `activeCall` (the enclosing call + which argument the cursor is in, for signature
help).
- `spec-expressions` — the expressions embedded in a spec's JSON strings (`EXPRESSION_KEYS` =
`calculate`/`filter`/`expr`/`test`; only string values, so object predicates are skipped).
`expressionStringsIn` locates each (byte span + key) to drive markers; `firstExpressionError`
names the first malformed one (key + parser message + 1-based line) so a failed render can
attribute itself.
- `vega-expr-catalog` — the expression language's function/constant **names derived from
`vega-expression`'s own registry** (zero drift; a test asserts the curated set ⊆ derived), plus
curated parameter signatures for the commonly-typed functions (what the registry can't supply).
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the **Services (app, store-aware via `getState`):** `spec-transform-actions` (the
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion, wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (data-column
hover, inlay providers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats completion, hover, inlay providers), `spec-expression-hints` (expression completion,
plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring. signature help, hover, and the diagnostic markers), `active-dataset` (`dataInfoAt(text,
offset)` — the columns/types/stats plus derived fields the draft sees at the cursor).
`SpecEditor` does the wiring.
Decision rules: Decision rules:
@@ -381,6 +395,19 @@ Decision rules:
- **No unknown-field diagnostic.** Hints are additive and forgiving, so over- or - **No unknown-field diagnostic.** Hints are additive and forgiving, so over- or
under-listing costs nothing; a "field not in data" squiggle would false-positive on every under-listing costs nothing; a "field not in data" squiggle would false-positive on every
derived or data-dependent field, so there is deliberately none. derived or data-dependent field, so there is deliberately none.
- **Expression intelligence is one service; its markers are per-editor.** `spec-expression-hints`
registers the expression completion/signature-help/hover **once for `json`** (like the other
providers), but the marker pass — validating every expression string and squiggling the invalid
ones with `setModelMarkers` (the app's only editor markers besides the JSON worker's, under the
`vega-expr` owner) — is **per editor**, since it writes to one model, and recomputes debounced on
edit and on a draft↔published toggle. All expression concerns (completion, hover, markers) live
here; `spec-dataset-hints` owns only data-column hints, so neither is a grab-bag.
- **Completion replace-ranges come from a self-parsed partial, never `getWordUntilPosition`.**
Monaco's JSON `wordPattern` counts `.` and `(` as word characters, so the model's "word" after
`datum.` or `fn(` spans the whole `datum.`/`fn(` token; used as a completion item's range it both
mis-targets the edit and filters every suggestion out (none start with `datum.`). A provider
completing inside a string must build the replace range from the partial it parses itself — a rule
any future in-string completion (transform/param scaffolding) inherits.
- **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's - **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's
`CodeAction` carries no icon field. Custom iconography lives only where it is supported: `CodeAction` carries no icon field. Custom iconography lives only where it is supported:
CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations. CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations.
@@ -34,12 +34,12 @@ Astrolabe has four distinct ways to tell the user something. They are **not**
interchangeable; picking the wrong one is the most common interaction bug. Choose by the interchangeable; picking the wrong one is the most common interaction bug. Choose by the
nature of the message, not by convenience. nature of the message, not by convenience.
| Channel | Use when | Blocks? | Dismissal | Implemented by | | Channel | Use when | Blocks? | Dismissal | Implemented by |
| -------------------- | --------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | ----------------------------------------------------- |
| **Confirm dialog** | A **destructive or irreversible** action needs explicit consent (delete, revert, reset) | Yes — modal | User must choose; Escape/Cancel = no; backdrop click does **not** dismiss | `ConfirmStore` + `ConfirmDialog` | | **Confirm dialog** | A **destructive or irreversible** action needs explicit consent (delete, revert, reset) | Yes — modal | User must choose; Escape/Cancel = no; backdrop click does **not** dismiss | `ConfirmStore` + `ConfirmDialog` |
| **Toast** | A **non-blocking outcome** happened the user should know about (save failed, published, imported) | No | Auto for success/info; persists for error/warning; always a close button | `NotificationStore` + `Toaster` | | **Toast** | A **non-blocking outcome** happened the user should know about (save failed, published, imported) | No | Auto for success/info; persists for error/warning; always a close button | `NotificationStore` + `Toaster` |
| **Inline error** | A problem is **tied to a specific surface** and recovers in place (invalid spec → editor + preview) | No | Clears automatically when the cause is fixed | `PreviewStore`, surfaced in `SpecEditor` + `LivePreview` | | **Inline error** | A problem is **tied to a specific surface** and recovers in place (invalid spec → preview, cause squiggled in the editor) | No | Clears automatically when the cause is fixed | `LivePreview` local state; `vega-expr` editor markers |
| **Status indicator** | **Passive, ambient** state worth glancing at (draft vs. published, storage usage) | No | N/A — it just reflects state | library draft dot; storage monitor (later) | | **Status indicator** | **Passive, ambient** state worth glancing at (draft vs. published, storage usage) | No | N/A — it just reflects state | library draft dot; storage monitor (later) |
**Rules.** **Rules.**
@@ -80,9 +80,12 @@ nature of the message, not by convenience.
the other publishes silently (the exact inconsistency this rule prevents). The shortcut is the other publishes silently (the exact inconsistency this rule prevents). The shortcut is
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
confirmed. confirmed.
- **The same failure can light up two channels.** An unrenderable spec shows the _same_ - **A render failure has one message home: the preview.** An unrenderable spec shows its
message inline in both the editor (§03E) and the preview (§04) — one producer message in the preview pane (§04), where the chart would be — error _xor_ chart, since the
(`PreviewStore`), two subscribers. That's intentional, not duplication. preview's `error` state is non-null only when the render failed (success/empty clear it; export
failures report through the export UI, never here). The editor marks the offending spot with
an inline squiggle (§03E) rather than repeating the text. Render status is `LivePreview`'s own
local state (`error`/`busy`) — one producer, one surface, so it needs no store.
## 2. Latency & feedback budgets ## 2. Latency & feedback budgets
@@ -437,11 +440,13 @@ data-first door ("Build a chart from your data") beside its primary. _(Consulted
docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not
the source.)_ the source.)_
**Resolved — one live region per shared message.** When the same error feeds two surfaces **Resolved — a render error lives in one place, the preview.** The render-error message
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly (`LivePreview`'s local `error` state) shows only in the preview pane, where the chart would be
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the (error _xor_ chart), and that single surface is the `role="alert"` live region — assertive, since the user
other shows the text visually with no live role. Two live regions would announce the same just caused it. It is announced regardless of where focus sits, so it needs no duplicate near
message twice. the editor; the editor instead pinpoints the cause with an inline squiggle. Messages share a
terse line-led / noun-led shape (`Line 14 · Unexpected end of input`, `Dataset "x" not found ·
…`, `Invalid JSON · …`) — the location or the problem noun first, then the parser detail.
**Resolved — inline _live_ validation feedback is polite, glyphed, and field-linked.** A **Resolved — inline _live_ validation feedback is polite, glyphed, and field-linked.** A
validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a
+5 -5
View File
@@ -57,14 +57,14 @@ Every snippet carries two versions of its spec: a **published** (stable) version
- On confirmation, the editor reloads with the published spec and a toast confirms the draft was reverted. - On confirmation, the editor reloads with the published spec and a toast confirms the draft was reverted.
- Revert is unavailable when no snippet is active. - Revert is unavailable when no snippet is active.
## E. Inline Error Surface ## E. Error Surface
When the spec cannot be parsed or cannot be rendered, the editor pane shows the problem clearly while keeping the user in place to fix it. When the spec cannot be parsed or cannot be rendered, the problem is shown clearly while keeping the user in place to fix it.
- When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference), a clear, readable error message appears in the editor pane, near the editor area. - When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference or a malformed Vega expression), a clear, readable error message appears in the **preview pane, in place of the chart** — a spec either renders or shows its error, never both.
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong. - The message is plainly legible (monospaced, distinct from normal content) and leads with the location or the problem, then the detail — for example `Line 14 · Unexpected end of input` or `Dataset "sales" not found · create it from Datasets`.
- In the **editor**, the offending spot is marked with an inline squiggle — a JSON syntax error where it occurs, a malformed expression on its own string — so the cause is locatable without leaving the code.
- The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully. - The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully.
- This is the editor-side error affordance only; how a valid spec is drawn lives in _Live Preview_.
## F. Extract Inline Data to a Dataset ## F. Extract Inline Data to a Dataset
+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 * The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests * `renderSpec` is mocked to park each embed in `H.pending` a test decides when
* set that flag directly and assert the DOM result no timing, no mocking of the * embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
* async render path. * 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'; 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 { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes'; import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { LivePreview } from './LivePreview'; import { LivePreview } from './LivePreview';
@@ -71,7 +71,6 @@ beforeEach(() => {
H.pending.length = 0; H.pending.length = 0;
H.destroyed.length = 0; H.destroyed.length = 0;
H.configs.length = 0; H.configs.length = 0;
usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset(); useSnippetStore.getState().reset();
useDatasetStore.getState().reset(); useDatasetStore.getState().reset();
@@ -84,11 +83,12 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); container.remove();
usePreviewStore.setState({ error: null, busy: false });
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('LivePreview busy overlay', () => { 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 // The overlay is the aria-hidden element carrying the "Rendering…" label — a
// bare [aria-hidden] query would also match decorative bits of the header // bare [aria-hidden] query would also match decorative bits of the header
// controls (e.g. the chart-theme select's caret). // controls (e.g. the chart-theme select's caret).
@@ -97,33 +97,44 @@ describe('LivePreview busy overlay', () => {
/rendering/i.test(el.textContent ?? ''), /rendering/i.test(el.textContent ?? ''),
) ?? null; ) ?? null;
test('does not render the busy overlay when busy=false', () => { // Start a render and leave it parked in `H.pending`; advancing past the debounce
// The overlay element should not be in the DOM at all during normal operation. // 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(); 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. // aria-busy="false" is technically valid but needlessly verbose; we omit it.
expect(container.querySelector('[aria-busy]')).toBeNull(); expect(container.querySelector('[aria-busy]')).toBeNull();
}); });
test('overlay disappears when busy returns to false', () => { test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
act(() => usePreviewStore.setState({ busy: true })); vi.useFakeTimers();
expect(overlay()).not.toBeNull(); try {
act(() => usePreviewStore.setState({ busy: false })); await startSlowRender();
expect(overlay()).toBeNull(); 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 * mode applied) renderSpec (vega-embed). A render-generation token guards
* against a slow render resolving after a newer one. * against a slow render resolving after a newer one.
* *
* The pane header carries the Fit control (4 sizing modes, §04). Render errors * The pane header carries the Fit control (4 sizing modes, §04). A render or
* are published to the shared PreviewStore so the editor pane mirrors them * parse error shows here in place of the chart (error xor chart) its single
* (§03E); the preview shows the same message in place of the chart. * 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 * M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
* (M3) plugs into prepareSpecForRender without changing this component. * (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 { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering'; import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { firstExpressionError } from '@core/spec-expressions';
import { import {
chartConfigForSelection, chartConfigForSelection,
chartThemeOptions, chartThemeOptions,
@@ -33,7 +35,6 @@ import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore'; import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport'; import { ChartExport } from './ChartExport';
@@ -198,10 +199,11 @@ export function LivePreview() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce); const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
// Seed with a sentinel epoch so the very first paint counts as a load (immediate). // Seed with a sentinel epoch so the very first paint counts as a load (immediate).
const lastLoadRef = useRef({ bufferEpoch: -1, editorView }); const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
const error = usePreviewStore((s) => s.error); // Render status is local to this pane — it is both the only producer and the
const setError = usePreviewStore((s) => s.setError); // only consumer, so it needs no store (arch 01). `error` is the render/parse
const busy = usePreviewStore((s) => s.busy); // message (null = clean or blank); `busy` gates the >1s render overlay.
const setBusy = usePreviewStore((s) => s.setBusy); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Mirrors whether `handleRef` currently holds a live view, so the per-chart // Mirrors whether `handleRef` currently holds a live view, so the per-chart
// export's image actions (which need the view) can enable/disable reactively — // 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 // a ref change alone wouldn't re-render. Set true on a successful render, false
@@ -253,7 +255,7 @@ export function LivePreview() {
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch (e) { } 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; return;
} }
} }
@@ -333,19 +335,25 @@ export function LivePreview() {
setChartReady(false); setChartReady(false);
setRenderEpoch((e) => e + 1); setRenderEpoch((e) => e + 1);
clearBusy(); clearBusy();
// A missing dataset reference is not a JSON/spec problem, so it gets a // All render-failure messages share a line-led / noun-led terse shape
// tailored, fixable message instead of the generic syntax hint (council: // (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix). // 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) { if (e instanceof DatasetNotFoundError) {
setError( setError(
`Dataset "${e.datasetName}" not found. Create it from Datasets ` + `Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
); );
} else { } else {
setError( // A malformed Vega expression is located by line (the editor also
`Rendering error: ${(e as Error).message}. ` + // squiggles it) and carries the same parser message the hover shows.
`Check your JSON syntax and that the spec is valid Vega-Lite.`, // 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(); return () => ro.disconnect();
}, []); }, []);
// Finalize the live view on unmount, and clear the shared error + busy state so // Finalize the live view on unmount so its timers/listeners don't outlive the
// stale transient state never outlives this pane. // pane. Render status is local state and dies with the component.
useEffect( useEffect(
() => () => { () => () => {
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; handleRef.current = null;
setChartReady(false); setChartReady(false);
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current); 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.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} /> <div className={styles.host} ref={hostRef} />
</div> </div>
{/* Visual only no live region. The same error is announced once by the {/* The single home for a render/parse error (arch 10 §1): it sits where the
editor pane's role="alert" (one producer, two subscribers; doc §10.1), chart would be error XOR chart and is the lone live region, assertive
so adding one here would double-announce it. */} since the user just caused it. The editor pinpoints the spot via its squiggle,
{error !== null && <pre className={styles.error}>{error}</pre>} 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 * 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 * 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); background: var(--bg);
pointer-events: none; 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 * 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 §03D). The published view is read-only it shows the last published
* spec for reference; all editing happens on the draft. Render problems surface * spec for reference; all editing happens on the draft. Render/parse problems
* inline near the editor (spec §03E), mirroring the preview via PreviewStore. * 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'; import { useEffect, useMemo, useRef, type RefObject } from 'react';
@@ -45,6 +46,10 @@ import {
runWrapViews, runWrapViews,
} from '../services/spec-transform-actions'; } from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints'; import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import {
configureSpecExpressionHints,
installExpressionMarkers,
} from '../services/spec-expression-hints';
import { runExtract } from '../services/extract-action'; import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
@@ -52,7 +57,6 @@ import { useDatasetStore } from '../stores/DatasetStore';
import { hasExtractableData } from '../stores/ExtractStore'; import { hasExtractableData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions'; import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon'; import { Icon } from './Icon';
@@ -167,6 +171,8 @@ configureJsonFormatter();
configureSpecTransformCodeActions(); configureSpecTransformCodeActions();
// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08). // Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
configureSpecDatasetHints(); configureSpecDatasetHints();
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
configureSpecExpressionHints();
/** The two specconfig operations, surfaced as an overflow menu (council: /** The two specconfig operations, surfaced as an overflow menu (council:
* Carbon menu-buttons overflow for additional options under space * Carbon menu-buttons overflow for additional options under space
@@ -390,7 +396,6 @@ export function SpecEditor() {
const uiTheme = useAppStore((s) => s.uiTheme); const uiTheme = useAppStore((s) => s.uiTheme);
const revealTarget = useAppStore((s) => s.revealTarget); const revealTarget = useAppStore((s) => s.revealTarget);
const composeRequest = useAppStore((s) => s.composeRequest); const composeRequest = useAppStore((s) => s.composeRequest);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change. // Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor); 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. // editor, because its commands need this editor's handle to apply the edit.
const codeLensSub = installSpecTransformCodeLens(editor); 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 → // Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the // "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco // interactive-context gate so it works while the editor has focus. Monaco
@@ -461,6 +471,7 @@ export function SpecEditor() {
configActionsSub.dispose(); configActionsSub.dispose();
transformActionsSub.dispose(); transformActionsSub.dispose();
codeLensSub.dispose(); codeLensSub.dispose();
exprMarkersSub.dispose();
editor.dispose(); editor.dispose();
editorRef.current = null; editorRef.current = null;
}; };
@@ -551,14 +562,8 @@ export function SpecEditor() {
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>} {activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} /> <div className={styles.editor} ref={hostRef} />
</div> </div>
{/* The single live region for render/parse errors: assertive, since the {/* Render/parse errors surface in the preview pane (arch 10 §1), where the
user just caused it. The preview shows the same text visually but is chart would be; the editor pinpoints the spot with its squiggle. */}
not a live region, so the message is announced once (doc §10.1). */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
</div> </div>
); );
} }
+4 -24
View File
@@ -8,8 +8,8 @@
* takes a string; it can't list your columns. Enum values (`type`, `mark`, ) * 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. * 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 * - **Hover** a column's inferred type + cardinality/range (from the stored
* profile); over a `calculate`/`filter`/`expr` string, a live validity check * profile). Monaco merges this with the schema's own hovers. (Expression-string
* via core/expr-validate. Monaco merges these with the schema's own hovers. * hovers and completion live in services/spec-expression-hints.)
* - **Inlay hints** a faint `·<data-type>` beside each `field` (the column's * - **Inlay hints** a faint `·<data-type>` beside each `field` (the column's
* raw type: number/string/date/boolean), annotation without touching the text. * raw type: number/string/date/boolean), annotation without touching the text.
* Deliberately the *data* type, not the encoding `type` they share the line, * 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 * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { validateExpression } from '@core/expr-validate'; import { valueKeyAtOffset } from '@core/spec-cursor';
import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { import {
availableFieldsAt, availableFieldsAt,
@@ -38,8 +37,6 @@ import {
/** Property values that reference a data field (where column names belong). */ /** Property values that reference a data field (where column names belong). */
const FIELD_KEYS = new Set(['field', 'groupby']); 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. */ /** Markdown hover for a field hint: type + stats for source, a note for derived. */
function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] { function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
@@ -109,25 +106,8 @@ export function configureSpecDatasetHints(): void {
const text = model.getValue(); const text = model.getValue();
const offset = model.getOffsetAt(position); 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. // 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); const word = model.getWordAtPosition(position);
if (word) { if (word) {
const info = dataInfoAt(text, offset); 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 draftpublished 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 { describe, it, expect } from 'vitest';
import { validateExpression, referencedFields } from './expr-validate'; import { validateExpression, referencedFields, activeCall } from './expr-validate';
describe('validateExpression', () => { describe('validateExpression', () => {
it('accepts a well-formed Vega expression', () => { it('accepts a well-formed Vega expression', () => {
@@ -37,3 +37,35 @@ describe('referencedFields', () => {
expect(referencedFields('datum.price *')).toEqual([]); 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; 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 * 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 * `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(', ')})`;
}