Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

216 lines · 6.9 KBRaw History
1import { ExpressionSyntaxError, tokenize, type Token, type TokenKind } from "./lexer.js";
2
3export 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]. */
13export 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
28export const STATUS_FUNCTIONS = new Set(["success", "failure", "cancelled", "always"]);
29
30class 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
185function describe(t: Token): string {
186 return t.kind === "eof" ? "end of expression" : `'${t.text}'`;
187}
188
189const cache = new Map<string, Node>();
190
191/** Parses an expression (without the surrounding `${{ }}`). Throws ExpressionSyntaxError. */
192export 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(). */
202export 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}