/** * Parser for the computed-variable DSL. * * Computed variables (openspec/specs/internal-dns-split-horizon/spec.md, D1) are * declared in a manifest with `type: computed` and a `value:` expression * derived on access from other values, e.g.: * * value: "keys(secret.ddns_passwords)" * value: "unique(concat(self.a, self.b))" * value: "format('{host}.{zone}', host=self.hostname, zone=system.primary_domain)" * * This module turns that string into an AST. It is PURE — no evaluation, * no lookups, no I/O. The grammar is deliberately tiny: a call over an * allow-list of functions whose arguments are variable references, string * literals, bare identifiers (field names), nested calls, or named args * (for `format`). It is NOT a general expression language — there is no * arithmetic, no control flow, no operators. */ /** Roots a variable reference may start with (`secret.ddns_passwords`). */ export const REF_ROOTS = ['self', 'system', 'secret', 'system_secret', 'capability'] as const; export type RefRoot = (typeof REF_ROOTS)[number]; const REF_ROOT_SET = new Set(REF_ROOTS); /** A function call: `keys(secret.ddns_passwords)`. */ export interface CallNode { kind: 'call'; fn: string; args: Arg[]; } /** A variable reference whose first segment is a known root. */ export interface RefNode { kind: 'ref'; root: RefRoot; /** Path segments after the root, e.g. `["ddns_passwords"]`. */ path: string[]; } /** A quoted string literal: `'{host}.{zone}'`. */ export interface StrNode { kind: 'str'; value: string; } /** * A bare (non-root) identifier, possibly dotted — used as a field-name * argument, e.g. the `ip` in `map(self.upstreams, ip)`. */ export interface BareNode { kind: 'bare'; name: string; } export type Node = CallNode | RefNode | StrNode | BareNode; /** A call argument — optionally named (`host=self.hostname`). */ export interface Arg { /** Present only for named args (used by `format`). */ name?: string; value: Node; } export class ComputedParseError extends Error { constructor( message: string, readonly position: number, ) { super(`Computed expression parse error at ${position}: ${message}`); this.name = 'ComputedParseError'; } } type TokenType = 'ident' | 'string' | '(' | ')' | ',' | '=' | '.'; interface Token { type: TokenType; value: string; pos: number; } const IDENT_START = /[a-zA-Z_]/; const IDENT_CHAR = /[a-zA-Z0-9_]/; function tokenize(input: string): Token[] { const tokens: Token[] = []; let i = 0; while (i < input.length) { const ch = input[i]; if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; } if (ch === '(' || ch === ')' || ch === ',' || ch === '=' || ch === '.') { tokens.push({ type: ch, value: ch, pos: i }); i++; continue; } if (ch === "'" || ch === '"') { const quote = ch; const start = i; i++; let value = ''; while (i < input.length && input[i] !== quote) { value += input[i]; i++; } if (i >= input.length) { throw new ComputedParseError('unterminated string literal', start); } i++; // closing quote tokens.push({ type: 'string', value, pos: start }); continue; } if (IDENT_START.test(ch)) { const start = i; let value = ''; while (i < input.length && IDENT_CHAR.test(input[i])) { value += input[i]; i++; } tokens.push({ type: 'ident', value, pos: start }); continue; } throw new ComputedParseError(`unexpected character '${ch}'`, i); } return tokens; } class Parser { private pos = 0; constructor(private readonly tokens: Token[]) {} parse(): Node { const node = this.parseExpr(); if (this.pos < this.tokens.length) { throw new ComputedParseError( `unexpected trailing input '${this.tokens[this.pos].value}'`, this.tokens[this.pos].pos, ); } return node; } private peek(): Token | undefined { return this.tokens[this.pos]; } private next(): Token { const t = this.tokens[this.pos]; if (!t) { throw new ComputedParseError('unexpected end of expression', -1); } this.pos++; return t; } private expect(type: TokenType): Token { const t = this.next(); if (t.type !== type) { throw new ComputedParseError(`expected '${type}' but found '${t.value}'`, t.pos); } return t; } private parseExpr(): Node { const t = this.peek(); if (!t) { throw new ComputedParseError('expected an expression', -1); } if (t.type === 'string') { this.next(); return { kind: 'str', value: t.value }; } if (t.type === 'ident') { // Function call? if (this.tokens[this.pos + 1]?.type === '(') { return this.parseCall(); } return this.parseRefOrBare(); } throw new ComputedParseError(`expected an expression but found '${t.value}'`, t.pos); } private parseCall(): CallNode { const fnTok = this.expect('ident'); this.expect('('); const args: Arg[] = []; if (this.peek()?.type !== ')') { for (;;) { args.push(this.parseArg()); if (this.peek()?.type === ',') { this.next(); continue; } break; } } this.expect(')'); return { kind: 'call', fn: fnTok.value, args }; } private parseArg(): Arg { // Named arg: IDENT '=' expr (used by format). const t = this.peek(); if (t?.type === 'ident' && this.tokens[this.pos + 1]?.type === '=') { const nameTok = this.next(); this.expect('='); return { name: nameTok.value, value: this.parseExpr() }; } return { value: this.parseExpr() }; } private parseRefOrBare(): RefNode | BareNode { const first = this.expect('ident'); const segments = [first.value]; while (this.peek()?.type === '.') { this.next(); segments.push(this.expect('ident').value); } if (REF_ROOT_SET.has(segments[0]) && segments.length > 1) { return { kind: 'ref', root: segments[0] as RefRoot, path: segments.slice(1), }; } // A known root with no path (`secret`) is meaningless; treat as bare so // the evaluator can produce a clear "unknown identifier" style error in // context rather than silently accepting it. return { kind: 'bare', name: segments.join('.') }; } } /** * Parse a computed-variable `value:` expression into an AST. * Throws {@link ComputedParseError} on malformed input. */ export function parseComputed(expression: string): Node { const tokens = tokenize(expression); if (tokens.length === 0) { throw new ComputedParseError('empty expression', 0); } return new Parser(tokens).parse(); }