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:
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
> 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
|
||||
|
||||
@@ -538,11 +538,12 @@ blank mid-edit.
|
||||
### A second preview surface: the Chart Builder
|
||||
|
||||
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
|
||||
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The
|
||||
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it
|
||||
does **not** reuse `LivePreview`; it runs its own small debounced render over the same
|
||||
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the
|
||||
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one
|
||||
(shown spec) and `AppStore` (fit mode/theme), and keeps its render status (`error`/`busy`) in
|
||||
its own local state. The **Chart Builder modal** needs a preview of a _different_ spec source
|
||||
(its config), so it does **not** reuse `LivePreview`; it runs its own small debounced render
|
||||
over the same `chart-renderer.renderSpec` + `prepareSpecForRender`, with its own **local**
|
||||
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`
|
||||
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
|
||||
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
|
||||
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 |
|
||||
| -------------------------------- | -------------------------------------- | ---------------------- |
|
||||
| Parse | Invalid JSON | "Invalid JSON: …" |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
|
||||
| Stage | Failure | Surfaced as |
|
||||
| -------------------------------- | -------------------------------------- | --------------------------------- |
|
||||
| Parse | Invalid JSON | `Invalid JSON · …` |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | `Dataset "x" not found · …` |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile, or a bad expression | `Line N · …` / `Render error · …` |
|
||||
|
||||
```ts
|
||||
// inside render(), driven by the debounced renderer
|
||||
@@ -620,7 +621,7 @@ async function render(): Promise<void> {
|
||||
if (!text) {
|
||||
current?.destroy();
|
||||
current = null;
|
||||
usePreviewStore.getState().setError(null);
|
||||
setError(null); // `error`/`busy` are the pane's local state, not a store
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -628,7 +629,7 @@ async function render(): Promise<void> {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -638,14 +639,9 @@ async function render(): Promise<void> {
|
||||
const config = chartConfigFor(uiTheme);
|
||||
current?.destroy();
|
||||
current = await renderSpec(node, prepared, config);
|
||||
usePreviewStore.getState().setError(null); // success clears any prior error
|
||||
setError(null); // success clears any prior error
|
||||
} catch (e) {
|
||||
usePreviewStore
|
||||
.getState()
|
||||
.setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
setError(`Render error · ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -660,10 +656,10 @@ manual retry, no reload.
|
||||
- **Do** treat empty/blank spec text as "render nothing" — finalize the current
|
||||
view, clear the error, show a clean empty pane.
|
||||
- **Do** clear the error state on every successful render.
|
||||
- **Do** make messages legible and actionable (the underlying reason plus a hint
|
||||
to check JSON/Vega-Lite validity), never a raw stack trace dump.
|
||||
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset
|
||||
not found vs Rendering error).
|
||||
- **Do** make messages legible and actionable — lead with the location or the failing
|
||||
stage, then the underlying reason — never a raw stack trace dump.
|
||||
- **Do** distinguish the failing stage in the message (invalid JSON vs missing dataset
|
||||
vs a bad expression or render error).
|
||||
- **Don't** show a broken/partial chart — replace the chart area with the
|
||||
message.
|
||||
- **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` |
|
||||
| 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_) |
|
||||
| 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`) |
|
||||
|
||||
@@ -277,10 +277,10 @@ defaults-spread" discipline is worth keeping.
|
||||
|
||||
## 5 · Editor augmentation (our layer over the borrowed base)
|
||||
|
||||
Beyond schema validation/completion (§1), the spec editor adds structural refactors and
|
||||
data-aware hints — the edits that are awkward in raw JSON and out of reach of the
|
||||
single-view visual builder. All transform logic is pure `src/core/`; the Monaco glue is
|
||||
thin app-layer services.
|
||||
Beyond schema validation/completion (§1), the spec editor adds structural refactors,
|
||||
data-aware hints, and expression intelligence — the edits and feedback that are awkward in
|
||||
raw JSON and out of reach of the single-view visual builder. All transform and analysis
|
||||
logic is pure `src/core/`; the Monaco glue is thin app-layer services.
|
||||
|
||||
**Core (pure, portable):**
|
||||
|
||||
@@ -321,11 +321,25 @@ thin app-layer services.
|
||||
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
|
||||
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
|
||||
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
|
||||
hover, inlay providers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
|
||||
plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring.
|
||||
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (data-column
|
||||
completion, hover, inlay providers), `spec-expression-hints` (expression completion,
|
||||
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:
|
||||
|
||||
@@ -381,6 +395,19 @@ Decision rules:
|
||||
- **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
|
||||
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
|
||||
`CodeAction` carries no icon field. Custom iconography lives only where it is supported:
|
||||
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
|
||||
nature of the message, not by convenience.
|
||||
|
||||
| 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` |
|
||||
| **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` |
|
||||
| **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) |
|
||||
| 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` |
|
||||
| **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 → 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) |
|
||||
|
||||
**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
|
||||
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
|
||||
confirmed.
|
||||
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
|
||||
message inline in both the editor (§03E) and the preview (§04) — one producer
|
||||
(`PreviewStore`), two subscribers. That's intentional, not duplication.
|
||||
- **A render failure has one message home: the preview.** An unrenderable spec shows its
|
||||
message in the preview pane (§04), where the chart would be — error _xor_ chart, since the
|
||||
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
|
||||
|
||||
@@ -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
|
||||
the source.)_
|
||||
|
||||
**Resolved — one live region per shared message.** When the same error feeds two surfaces
|
||||
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
|
||||
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
|
||||
other shows the text visually with no live role. Two live regions would announce the same
|
||||
message twice.
|
||||
**Resolved — a render error lives in one place, the preview.** The render-error message
|
||||
(`LivePreview`'s local `error` state) shows only in the preview pane, where the chart would be
|
||||
(error _xor_ chart), and that single surface is the `role="alert"` live region — assertive, since the user
|
||||
just caused it. It is announced regardless of where focus sits, so it needs no duplicate near
|
||||
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
|
||||
validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong.
|
||||
- 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 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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