Files
astrolabe/src/core/expr-validate.ts
T

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;
}