/** * 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.` 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.` (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(); 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; 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.` / `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 | null { const object = member.object as Record | undefined; if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null; const property = member.property as Record | 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; }