mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
/**
|
|
* Vega expression validation (spec §06 → Data; chart-builder enhancement 1E).
|
|
*
|
|
* Portable core: no browser APIs, no React. The Chart Builder's filter
|
|
* (expression mode) and calculated-field controls let the user write a raw Vega
|
|
* expression; this module checks it with **the same parser Vega-Lite uses at render
|
|
* time** (`vega-expression`'s `parseExpression`), so the inline "is this valid?"
|
|
* feedback agrees exactly with what the chart will accept — no second, divergent
|
|
* grammar. It also extracts the `datum.<field>` references so the UI can flag a typo
|
|
* against the dataset's actual columns before the chart silently renders empty.
|
|
*
|
|
* `vega-expression` is declared as a direct dependency but adds no bundle weight —
|
|
* Vega already ships it for rendering; this import reuses the same copy.
|
|
*/
|
|
|
|
import { parseExpression } from 'vega-expression';
|
|
|
|
/** The outcome of validating one expression string. */
|
|
export interface ExprValidation {
|
|
/** True when the expression parses (or is empty — an empty field isn't an error). */
|
|
valid: boolean;
|
|
/** A short parser message when invalid; absent when valid. */
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Validate a Vega expression. An **empty** string is treated as valid (it is an
|
|
* incomplete entry, not a mistake — the assembler skips it), so the UI shows no
|
|
* error until the user actually types something malformed. A parse failure returns
|
|
* the parser's message, trimmed of the noisy position suffix where present.
|
|
*/
|
|
export function validateExpression(expr: string): ExprValidation {
|
|
if (expr.trim() === '') return { valid: true };
|
|
try {
|
|
parseExpression(expr);
|
|
return { valid: true };
|
|
} catch (e) {
|
|
return { valid: false, error: cleanParserMessage((e as Error).message) };
|
|
}
|
|
}
|
|
|
|
/** Tidy a vega-expression parse error for inline display (drop a trailing "(N)"). */
|
|
function cleanParserMessage(message: string): string {
|
|
return message.replace(/\s*\(\d+\)\s*$/, '').trim() || 'Invalid expression.';
|
|
}
|
|
|
|
/**
|
|
* The distinct `datum.<field>` (and `datum['field']`) column names an expression
|
|
* references, in first-seen order — best-effort, for flagging unknown fields in the
|
|
* UI. Returns an empty list for an empty or unparseable expression (validation
|
|
* surfaces the parse error separately). Only direct member access off `datum` is
|
|
* collected; dynamic access (`datum[someVar]`) is not a static field name and is
|
|
* skipped.
|
|
*/
|
|
export function referencedFields(expr: string): string[] {
|
|
if (expr.trim() === '') return [];
|
|
let ast: unknown;
|
|
try {
|
|
ast = parseExpression(expr);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const fields: string[] = [];
|
|
const seen = new Set<string>();
|
|
const visit = (node: unknown): void => {
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visit(child);
|
|
return;
|
|
}
|
|
if (!node || typeof node !== 'object') return;
|
|
const rec = node as Record<string, unknown>;
|
|
if (rec.type === 'MemberExpression') {
|
|
const name = datumMemberName(rec);
|
|
if (name !== null && !seen.has(name)) {
|
|
seen.add(name);
|
|
fields.push(name);
|
|
}
|
|
}
|
|
for (const key of Object.keys(rec)) {
|
|
if (key === 'type') continue;
|
|
visit(rec[key]);
|
|
}
|
|
};
|
|
visit(ast);
|
|
return fields;
|
|
}
|
|
|
|
/** The function call a cursor sits inside, and which argument it is on. */
|
|
export interface ActiveCall {
|
|
/** The called function's name (the identifier before the open paren). */
|
|
name: string;
|
|
/** Zero-based index of the argument the cursor is in (commas seen so far). */
|
|
activeParam: number;
|
|
}
|
|
|
|
/**
|
|
* Given the expression text from its start up to the cursor, the innermost
|
|
* function call the cursor sits inside — its name and the argument index — or
|
|
* `null` when the cursor is not within a call. Used to drive editor signature
|
|
* help. A forward scan keeps a stack of bracket frames (parens and square
|
|
* brackets), skips string literals, and counts commas per frame; the nearest
|
|
* unclosed frame whose open paren follows an identifier is the active call, and
|
|
* that frame's comma count is the active argument.
|
|
*/
|
|
export function activeCall(prefix: string): ActiveCall | null {
|
|
interface Frame {
|
|
name: string | null;
|
|
commas: number;
|
|
}
|
|
const stack: Frame[] = [];
|
|
for (let i = 0; i < prefix.length; i++) {
|
|
const c = prefix[i];
|
|
if (c === '\\') {
|
|
i++; // an escape consumes the next character
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'") {
|
|
// Skip a string literal so its parens/commas don't disturb the scan.
|
|
const quote = c;
|
|
i++;
|
|
while (i < prefix.length && prefix[i] !== quote) {
|
|
if (prefix[i] === '\\') i++;
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
if (c === '(') {
|
|
const name = /([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(prefix.slice(0, i));
|
|
stack.push({ name: name ? name[1] : null, commas: 0 });
|
|
} else if (c === '[') {
|
|
stack.push({ name: null, commas: 0 });
|
|
} else if (c === ')' || c === ']') {
|
|
stack.pop();
|
|
} else if (c === ',' && stack.length > 0) {
|
|
stack[stack.length - 1].commas++;
|
|
}
|
|
}
|
|
for (let i = stack.length - 1; i >= 0; i--) {
|
|
const frame = stack[i];
|
|
if (frame.name !== null) return { name: frame.name, activeParam: frame.commas };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The static field name of a `datum.<name>` / `datum['name']` member access, or
|
|
* `null` when the node isn't such an access (a different object, computed-dynamic
|
|
* access, or a non-string key).
|
|
*/
|
|
function datumMemberName(member: Record<string, unknown>): string | null {
|
|
const object = member.object as Record<string, unknown> | undefined;
|
|
if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null;
|
|
const property = member.property as Record<string, unknown> | undefined;
|
|
if (!property) return null;
|
|
if (member.computed) {
|
|
// datum['field'] — a string literal key is a static field name.
|
|
return property.type === 'Literal' && typeof property.value === 'string'
|
|
? property.value
|
|
: null;
|
|
}
|
|
// datum.field — an identifier key.
|
|
return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null;
|
|
}
|