| 1 | import { ExpressionSyntaxError, tokenize, type Token, type TokenKind } from "./lexer.js"; |
| 2 | |
| 3 | export type Node = |
| 4 | | { type: "literal"; value: null | boolean | number | string } |
| 5 | | { type: "context"; name: string } |
| 6 | | { type: "index"; object: Node; index: Node | "*" } |
| 7 | | { type: "call"; name: string; args: Node[] } |
| 8 | | { type: "not"; operand: Node } |
| 9 | | { type: "binary"; op: "==" | "!=" | "<" | "<=" | ">" | ">="; left: Node; right: Node } |
| 10 | | { type: "logical"; op: "&&" | "||"; left: Node; right: Node }; |
| 11 | |
| 12 | /** Built-in functions: lower-cased name -> [min args, max args]. */ |
| 13 | export const FUNCTIONS: Record<string, { name: string; min: number; max: number }> = { |
| 14 | contains: { name: "contains", min: 2, max: 2 }, |
| 15 | startswith: { name: "startsWith", min: 2, max: 2 }, |
| 16 | endswith: { name: "endsWith", min: 2, max: 2 }, |
| 17 | format: { name: "format", min: 1, max: Infinity }, |
| 18 | join: { name: "join", min: 1, max: 2 }, |
| 19 | tojson: { name: "toJSON", min: 1, max: 1 }, |
| 20 | fromjson: { name: "fromJSON", min: 1, max: 1 }, |
| 21 | hashfiles: { name: "hashFiles", min: 1, max: Infinity }, |
| 22 | success: { name: "success", min: 0, max: 0 }, |
| 23 | failure: { name: "failure", min: 0, max: 0 }, |
| 24 | cancelled: { name: "cancelled", min: 0, max: 0 }, |
| 25 | always: { name: "always", min: 0, max: 0 }, |
| 26 | }; |
| 27 | |
| 28 | export const STATUS_FUNCTIONS = new Set(["success", "failure", "cancelled", "always"]); |
| 29 | |
| 30 | class Parser { |
| 31 | private i = 0; |
| 32 | constructor( |
| 33 | private readonly src: string, |
| 34 | private readonly tokens: Token[], |
| 35 | ) {} |
| 36 | |
| 37 | private peek(): Token { |
| 38 | return this.tokens[this.i]!; |
| 39 | } |
| 40 | private next(): Token { |
| 41 | return this.tokens[this.i++]!; |
| 42 | } |
| 43 | private fail(message: string, tok: Token = this.peek()): never { |
| 44 | throw new ExpressionSyntaxError(message, this.src, tok.pos); |
| 45 | } |
| 46 | private expect(kind: TokenKind): Token { |
| 47 | const t = this.peek(); |
| 48 | if (t.kind !== kind) this.fail(`Expected '${kind}' but found ${describe(t)}`); |
| 49 | return this.next(); |
| 50 | } |
| 51 | |
| 52 | parse(): Node { |
| 53 | if (this.peek().kind === "eof") this.fail("Empty expression"); |
| 54 | const node = this.parseOr(); |
| 55 | if (this.peek().kind !== "eof") this.fail(`Unexpected ${describe(this.peek())}`); |
| 56 | return node; |
| 57 | } |
| 58 | |
| 59 | private parseOr(): Node { |
| 60 | let left = this.parseAnd(); |
| 61 | while (this.peek().kind === "||") { |
| 62 | this.next(); |
| 63 | left = { type: "logical", op: "||", left, right: this.parseAnd() }; |
| 64 | } |
| 65 | return left; |
| 66 | } |
| 67 | |
| 68 | private parseAnd(): Node { |
| 69 | let left = this.parseEquality(); |
| 70 | while (this.peek().kind === "&&") { |
| 71 | this.next(); |
| 72 | left = { type: "logical", op: "&&", left, right: this.parseEquality() }; |
| 73 | } |
| 74 | return left; |
| 75 | } |
| 76 | |
| 77 | private parseEquality(): Node { |
| 78 | let left = this.parseComparison(); |
| 79 | for (;;) { |
| 80 | const k = this.peek().kind; |
| 81 | if (k !== "==" && k !== "!=") return left; |
| 82 | this.next(); |
| 83 | left = { type: "binary", op: k, left, right: this.parseComparison() }; |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | private parseComparison(): Node { |
| 88 | let left = this.parseUnary(); |
| 89 | for (;;) { |
| 90 | const k = this.peek().kind; |
| 91 | if (k !== "<" && k !== "<=" && k !== ">" && k !== ">=") return left; |
| 92 | this.next(); |
| 93 | left = { type: "binary", op: k, left, right: this.parseUnary() }; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | private parseUnary(): Node { |
| 98 | if (this.peek().kind === "!") { |
| 99 | this.next(); |
| 100 | return { type: "not", operand: this.parseUnary() }; |
| 101 | } |
| 102 | return this.parsePostfix(this.parsePrimary()); |
| 103 | } |
| 104 | |
| 105 | private parsePostfix(node: Node): Node { |
| 106 | for (;;) { |
| 107 | const t = this.peek(); |
| 108 | if (t.kind === ".") { |
| 109 | this.next(); |
| 110 | const p = this.peek(); |
| 111 | if (p.kind === "*") { |
| 112 | this.next(); |
| 113 | node = { type: "index", object: node, index: "*" }; |
| 114 | } else if (p.kind === "ident" || p.kind === "true" || p.kind === "false" || p.kind === "null") { |
| 115 | this.next(); |
| 116 | node = { type: "index", object: node, index: { type: "literal", value: p.text } }; |
| 117 | } else { |
| 118 | this.fail(`Expected property name after '.' but found ${describe(p)}`); |
| 119 | } |
| 120 | } else if (t.kind === "[") { |
| 121 | this.next(); |
| 122 | if (this.peek().kind === "*") { |
| 123 | this.next(); |
| 124 | this.expect("]"); |
| 125 | node = { type: "index", object: node, index: "*" }; |
| 126 | } else { |
| 127 | const index = this.parseOr(); |
| 128 | this.expect("]"); |
| 129 | node = { type: "index", object: node, index }; |
| 130 | } |
| 131 | } else { |
| 132 | return node; |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | private parsePrimary(): Node { |
| 138 | const t = this.next(); |
| 139 | switch (t.kind) { |
| 140 | case "number": |
| 141 | case "string": |
| 142 | return { type: "literal", value: t.value! }; |
| 143 | case "true": |
| 144 | return { type: "literal", value: true }; |
| 145 | case "false": |
| 146 | return { type: "literal", value: false }; |
| 147 | case "null": |
| 148 | return { type: "literal", value: null }; |
| 149 | case "(": { |
| 150 | const inner = this.parseOr(); |
| 151 | this.expect(")"); |
| 152 | return inner; |
| 153 | } |
| 154 | case "ident": { |
| 155 | if (this.peek().kind === "(") { |
| 156 | this.next(); |
| 157 | const fn = FUNCTIONS[t.text.toLowerCase()]; |
| 158 | if (!fn) this.fail(`Unrecognized function '${t.text}'`, t); |
| 159 | const args: Node[] = []; |
| 160 | if (this.peek().kind !== ")") { |
| 161 | for (;;) { |
| 162 | args.push(this.parseOr()); |
| 163 | if (this.peek().kind === ",") { |
| 164 | this.next(); |
| 165 | continue; |
| 166 | } |
| 167 | break; |
| 168 | } |
| 169 | } |
| 170 | this.expect(")"); |
| 171 | if (args.length < fn.min || args.length > fn.max) { |
| 172 | const expected = fn.min === fn.max ? `${fn.min}` : fn.max === Infinity ? `at least ${fn.min}` : `${fn.min}-${fn.max}`; |
| 173 | this.fail(`Function '${fn.name}' expects ${expected} argument(s) but got ${args.length}`, t); |
| 174 | } |
| 175 | return { type: "call", name: t.text.toLowerCase(), args }; |
| 176 | } |
| 177 | return { type: "context", name: t.text }; |
| 178 | } |
| 179 | default: |
| 180 | return this.fail(`Unexpected ${describe(t)}`, t); |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | function describe(t: Token): string { |
| 186 | return t.kind === "eof" ? "end of expression" : `'${t.text}'`; |
| 187 | } |
| 188 | |
| 189 | const cache = new Map<string, Node>(); |
| 190 | |
| 191 | /** Parses an expression (without the surrounding `${{ }}`). Throws ExpressionSyntaxError. */ |
| 192 | export function parseExpression(src: string): Node { |
| 193 | const hit = cache.get(src); |
| 194 | if (hit) return hit; |
| 195 | const node = new Parser(src, tokenize(src)).parse(); |
| 196 | if (cache.size > 2000) cache.clear(); |
| 197 | cache.set(src, node); |
| 198 | return node; |
| 199 | } |
| 200 | |
| 201 | /** True if the AST calls success(), failure(), cancelled() or always(). */ |
| 202 | export function usesStatusFunction(node: Node): boolean { |
| 203 | switch (node.type) { |
| 204 | case "call": |
| 205 | return STATUS_FUNCTIONS.has(node.name) || node.args.some(usesStatusFunction); |
| 206 | case "index": |
| 207 | return usesStatusFunction(node.object) || (node.index !== "*" && usesStatusFunction(node.index)); |
| 208 | case "not": |
| 209 | return usesStatusFunction(node.operand); |
| 210 | case "binary": |
| 211 | case "logical": |
| 212 | return usesStatusFunction(node.left) || usesStatusFunction(node.right); |
| 213 | default: |
| 214 | return false; |
| 215 | } |
| 216 | } |