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
|
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.
|
||||||
|
|||||||
@@ -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`) |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ interchangeable; picking the wrong one is the most common interaction bug. Choos
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
try {
|
||||||
|
await startSlowRender();
|
||||||
expect(overlay()).not.toBeNull();
|
expect(overlay()).not.toBeNull();
|
||||||
act(() => usePreviewStore.setState({ busy: false }));
|
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();
|
expect(overlay()).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 spec↔config operations, surfaced as an overflow menu (council:
|
/** The two spec↔config 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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