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.

143 lines · 4.3 KBRaw History
1/**
2 * Tokenizer for the GitHub Actions expression language
3 * (https://docs.github.com/en/actions/learn-github-actions/expressions).
4 */
5
6export type TokenKind =
7 | "number"
8 | "string"
9 | "ident"
10 | "true"
11 | "false"
12 | "null"
13 | "("
14 | ")"
15 | "["
16 | "]"
17 | "."
18 | ","
19 | "*"
20 | "!"
21 | "<"
22 | "<="
23 | ">"
24 | ">="
25 | "=="
26 | "!="
27 | "&&"
28 | "||"
29 | "eof";
30
31export interface Token {
32 kind: TokenKind;
33 /** Raw source text of the token. */
34 text: string;
35 /** Parsed value for number/string literals. */
36 value?: number | string;
37 /** Offset in the expression source. */
38 pos: number;
39}
40
41export class ExpressionSyntaxError extends Error {
42 constructor(
43 message: string,
44 readonly expression: string,
45 readonly pos: number,
46 ) {
47 super(`${message} (at position ${pos + 1} in expression: ${expression})`);
48 this.name = "ExpressionSyntaxError";
49 }
50}
51
52const isDigit = (c: string | undefined): boolean => c !== undefined && c >= "0" && c <= "9";
53const isIdentStart = (c: string | undefined): boolean =>
54 c !== undefined && ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_");
55const isIdentPart = (c: string | undefined): boolean => isIdentStart(c) || isDigit(c) || c === "-";
56
57const NUMBER_RE = /^[+-]?(?:0[xX][0-9a-fA-F]+|0[oO][0-7]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/;
58
59/** Parses the numeric literal syntax used by GitHub (JSON numbers, hex, octal, exponent). */
60export function parseNumberLiteral(text: string): number {
61 const t = text.trim();
62 const sign = t.startsWith("-") ? -1 : 1;
63 const body = t.replace(/^[+-]/, "");
64 if (/^0[xX][0-9a-fA-F]+$/.test(body)) return sign * parseInt(body.slice(2), 16);
65 if (/^0[oO][0-7]+$/.test(body)) return sign * parseInt(body.slice(2), 8);
66 return Number(t);
67}
68
69export function tokenize(src: string): Token[] {
70 const tokens: Token[] = [];
71 let i = 0;
72 const push = (kind: TokenKind, text: string, pos: number, value?: number | string) =>
73 tokens.push(value === undefined ? { kind, text, pos } : { kind, text, pos, value });
74
75 while (i < src.length) {
76 const c = src[i]!;
77 if (c === " " || c === "\t" || c === "\n" || c === "\r") {
78 i++;
79 continue;
80 }
81 const start = i;
82 // String literal: single quotes, '' escapes a quote.
83 if (c === "'") {
84 let out = "";
85 i++;
86 for (;;) {
87 if (i >= src.length) throw new ExpressionSyntaxError("Unterminated string literal", src, start);
88 const ch = src[i]!;
89 if (ch === "'") {
90 if (src[i + 1] === "'") {
91 out += "'";
92 i += 2;
93 continue;
94 }
95 i++;
96 break;
97 }
98 out += ch;
99 i++;
100 }
101 push("string", src.slice(start, i), start, out);
102 continue;
103 }
104 if (c === '"') {
105 throw new ExpressionSyntaxError("Unexpected double quote; string literals use single quotes", src, start);
106 }
107 // Numbers (a leading '-' or '+' is part of the literal, there is no unary minus).
108 if (isDigit(c) || ((c === "-" || c === "+" || c === ".") && (isDigit(src[i + 1]) || (src[i + 1] === "." && isDigit(src[i + 2]))))) {
109 const m = NUMBER_RE.exec(src.slice(i));
110 if (!m) throw new ExpressionSyntaxError("Invalid number", src, start);
111 const text = m[0];
112 if (isIdentPart(src[i + text.length]) && src[i + text.length] !== "-") {
113 throw new ExpressionSyntaxError(`Invalid number '${text}${src[i + text.length]}'`, src, start);
114 }
115 i += text.length;
116 push("number", text, start, parseNumberLiteral(text));
117 continue;
118 }
119 if (isIdentStart(c)) {
120 while (isIdentPart(src[i])) i++;
121 const text = src.slice(start, i);
122 if (text === "true" || text === "false" || text === "null") push(text, text, start);
123 else if (text === "NaN") push("number", text, start, NaN);
124 else if (text === "Infinity") push("number", text, start, Infinity);
125 else push("ident", text, start);
126 continue;
127 }
128 const two = src.slice(i, i + 2);
129 if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
130 push(two, two, start);
131 i += 2;
132 continue;
133 }
134 if ("()[].,*!<>".includes(c)) {
135 push(c as TokenKind, c, start);
136 i++;
137 continue;
138 }
139 throw new ExpressionSyntaxError(`Unexpected character '${c}'`, src, start);
140 }
141 push("eof", "", src.length);
142 return tokens;
143}