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.

393 lines · 11.9 KBRaw History
1/**
2 * Minimal YAML parser covering what `action.yml` files use in practice:
3 * block mappings/sequences, block scalars (| > with chomping), quoted and
4 * plain scalars (incl. multi-line plain), flow collections and comments.
5 * Anchors/aliases/tags are not supported (tags and anchors are ignored).
6 */
7
8interface Line {
9 indent: number;
10 text: string; // without leading indentation
11 raw: string;
12}
13
14const KEY_RE = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s#'"\-?:,\[\]{}][^#]*?|-[^\s][^#]*?|\?[^\s][^#]*?)\s*:(?=\s|$)/;
15
16export class YamlError extends Error {}
17
18export function parseYaml(src: string): unknown {
19 const lines: Line[] = src
20 .replace(/^/, "")
21 .split(/\r?\n/)
22 .map((raw) => {
23 const m = /^( *)/.exec(raw)!;
24 return { indent: m[1]!.length, text: raw.slice(m[1]!.length), raw };
25 });
26 const p = new Parser(lines);
27 return p.document();
28}
29
30function isBlank(l: Line): boolean {
31 const t = l.text.trim();
32 return t === "" || t.startsWith("#");
33}
34
35function isSeqItem(text: string): boolean {
36 return text === "-" || text.startsWith("- ") || text.startsWith("-\t");
37}
38
39class Parser {
40 pos = 0;
41 constructor(private lines: Line[]) {}
42
43 document(): unknown {
44 // skip directives / document start
45 this.skipBlank();
46 while (this.pos < this.lines.length && (this.lines[this.pos]!.text.startsWith("%") || this.lines[this.pos]!.text.trim() === "---")) {
47 this.pos++;
48 this.skipBlank();
49 }
50 if (this.pos >= this.lines.length) return null;
51 const first = this.lines[this.pos]!;
52 const v = this.node(first.indent);
53 return v;
54 }
55
56 skipBlank(): void {
57 while (this.pos < this.lines.length && isBlank(this.lines[this.pos]!)) this.pos++;
58 }
59
60 peek(): Line | undefined {
61 this.skipBlank();
62 const l = this.lines[this.pos];
63 if (l && (l.text.trim() === "---" || l.text.trim() === "...") && l.indent === 0) return undefined;
64 return l;
65 }
66
67 node(indent: number): unknown {
68 const l = this.peek();
69 if (!l || l.indent < indent) return null;
70 if (isSeqItem(l.text)) return this.sequence(l.indent);
71 if (KEY_RE.test(l.text)) return this.mapping(l.indent);
72 // a bare scalar (possibly multi-line)
73 this.pos++;
74 return this.inlineValue(l.text, l.indent - 1);
75 }
76
77 mapping(indent: number): Record<string, unknown> {
78 const out: Record<string, unknown> = {};
79 for (;;) {
80 const l = this.peek();
81 if (!l || l.indent !== indent) break;
82 const m = KEY_RE.exec(l.text);
83 if (!m) break;
84 const keyRaw = m[0].replace(/\s*:$/, "");
85 const key = String(scalarFromToken(keyRaw.replace(/^\?\s*/, "")));
86 const rest = l.text.slice(m[0].length);
87 this.pos++;
88 out[key] = this.valueAfterIndicator(rest, indent, true);
89 }
90 return out;
91 }
92
93 sequence(indent: number): unknown[] {
94 const out: unknown[] = [];
95 for (;;) {
96 const l = this.peek();
97 if (!l || l.indent !== indent || !isSeqItem(l.text)) break;
98 const afterDash = l.text.slice(1);
99 const spaces = /^[ \t]*/.exec(afterDash)![0].length;
100 const rest = afterDash.slice(spaces);
101 if (rest === "" || rest.startsWith("#")) {
102 this.pos++;
103 const n = this.peek();
104 out.push(n && n.indent > indent ? this.node(n.indent) : null);
105 continue;
106 }
107 if (KEY_RE.test(rest) || isSeqItem(rest)) {
108 // rewrite this line as a nested node at a deeper indent
109 const newIndent = indent + 1 + spaces;
110 this.lines[this.pos] = { indent: newIndent, text: rest, raw: l.raw };
111 out.push(this.node(newIndent));
112 continue;
113 }
114 this.pos++;
115 out.push(this.valueAfterIndicator(rest, indent, false));
116 }
117 return out;
118 }
119
120 /** Parses what follows `key:` or `- `. */
121 valueAfterIndicator(rest: string, indent: number, inMapping: boolean): unknown {
122 let r = rest.replace(/^[ \t]+/, "");
123 // strip tags / anchors
124 r = r.replace(/^(?:![^\s]*|&[^\s]+)\s*/, "");
125 if (r === "" || r.startsWith("#")) {
126 const n = this.peek();
127 if (!n) return null;
128 if (n.indent > indent) return this.node(n.indent);
129 if (inMapping && n.indent === indent && isSeqItem(n.text)) return this.sequence(indent);
130 return null;
131 }
132 if (r[0] === "|" || r[0] === ">") return this.blockScalar(r, indent);
133 return this.inlineValue(r, indent);
134 }
135
136 blockScalar(header: string, parentIndent: number): string {
137 const h = /^([|>])([+-]?)(\d?)([+-]?)/.exec(header)!;
138 const folded = h[1] === ">";
139 const chomp = h[2] || h[4] || "";
140 const explicit = h[3] ? Number(h[3]) : 0;
141 const body: string[] = [];
142 let contentIndent = explicit ? parentIndent + explicit : -1;
143 while (this.pos < this.lines.length) {
144 const l = this.lines[this.pos]!;
145 if (l.raw.trim() === "") {
146 body.push("");
147 this.pos++;
148 continue;
149 }
150 if (contentIndent < 0) {
151 if (l.indent <= parentIndent) break;
152 contentIndent = l.indent;
153 }
154 if (l.indent < contentIndent) break;
155 body.push(l.raw.slice(contentIndent));
156 this.pos++;
157 }
158 // trailing blank lines
159 let trailing = 0;
160 while (body.length && body[body.length - 1] === "") {
161 body.pop();
162 trailing++;
163 }
164 let text: string;
165 if (folded) {
166 text = "";
167 for (let i = 0; i < body.length; i++) {
168 const cur = body[i]!;
169 if (i === 0) {
170 text = cur;
171 continue;
172 }
173 const prev = body[i - 1]!;
174 const moreIndented = /^\s/.test(cur) || /^\s/.test(prev);
175 if (cur === "") text += "\n";
176 else if (prev === "" || moreIndented) text += (prev === "" ? "" : "\n") + cur;
177 else text += " " + cur;
178 }
179 } else {
180 text = body.join("\n");
181 }
182 if (body.length === 0) return chomp === "+" ? "\n".repeat(trailing) : "";
183 if (chomp === "-") return text;
184 if (chomp === "+") return text + "\n" + "\n".repeat(trailing);
185 return text + "\n";
186 }
187
188 inlineValue(first: string, indent: number): unknown {
189 let text = first;
190 const c = text[0];
191 if (c === "[" || c === "{") {
192 // gather until balanced
193 while (!flowBalanced(text) && this.pos < this.lines.length) {
194 text += " " + this.lines[this.pos]!.text.trim();
195 this.pos++;
196 }
197 const fp = new FlowParser(text);
198 return fp.value();
199 }
200 if (c === '"' || c === "'") {
201 // quoted scalar, may span lines
202 while (!quoteClosed(text) && this.pos < this.lines.length) {
203 const nxt = this.lines[this.pos]!.text.trim();
204 text += nxt === "" ? "\n" : " " + nxt;
205 this.pos++;
206 }
207 return parseQuoted(text).value;
208 }
209 // plain scalar, may continue on more-indented lines
210 let value = stripComment(text).trim();
211 for (;;) {
212 const l = this.lines[this.pos];
213 if (!l) break;
214 if (l.text.trim() === "") {
215 // a blank line in a multi-line plain scalar becomes a newline, only if followed by continuation
216 let j = this.pos;
217 while (j < this.lines.length && this.lines[j]!.text.trim() === "") j++;
218 const nx = this.lines[j];
219 if (nx && nx.indent > indent && !nx.text.startsWith("#") && !KEY_RE.test(nx.text)) {
220 value += "\n".repeat(j - this.pos);
221 this.pos = j;
222 continue;
223 }
224 break;
225 }
226 if (l.indent <= indent || l.text.startsWith("#")) break;
227 if (KEY_RE.test(l.text) || isSeqItem(l.text)) break;
228 const add = stripComment(l.text).trim();
229 value += value.endsWith("\n") ? add : " " + add;
230 this.pos++;
231 }
232 return scalarFromToken(value);
233 }
234}
235
236function stripComment(s: string): string {
237 const i = s.search(/(^|\s)#/);
238 return i < 0 ? s : s.slice(0, i);
239}
240
241function quoteClosed(text: string): boolean {
242 try {
243 parseQuoted(text);
244 return true;
245 } catch {
246 return false;
247 }
248}
249
250function parseQuoted(text: string): { value: string; end: number } {
251 const q = text[0]!;
252 let out = "";
253 let i = 1;
254 if (q === "'") {
255 for (; i < text.length; i++) {
256 const ch = text[i]!;
257 if (ch === "'") {
258 if (text[i + 1] === "'") {
259 out += "'";
260 i++;
261 continue;
262 }
263 return { value: out, end: i + 1 };
264 }
265 out += ch;
266 }
267 throw new YamlError("unterminated single-quoted string");
268 }
269 for (; i < text.length; i++) {
270 const ch = text[i]!;
271 if (ch === "\\") {
272 const n = text[++i];
273 switch (n) {
274 case "n": out += "\n"; break;
275 case "t": out += "\t"; break;
276 case "r": out += "\r"; break;
277 case "0": out += "\0"; break;
278 case "\\": out += "\\"; break;
279 case '"': out += '"'; break;
280 case "/": out += "/"; break;
281 case " ": out += " "; break;
282 case "e": out += "\x1b"; break;
283 case "x": out += String.fromCharCode(parseInt(text.slice(i + 1, i + 3), 16)); i += 2; break;
284 case "u": out += String.fromCharCode(parseInt(text.slice(i + 1, i + 5), 16)); i += 4; break;
285 default: out += n ?? "";
286 }
287 continue;
288 }
289 if (ch === '"') return { value: out, end: i + 1 };
290 out += ch;
291 }
292 throw new YamlError("unterminated double-quoted string");
293}
294
295function flowBalanced(text: string): boolean {
296 let depth = 0;
297 let q: string | null = null;
298 for (let i = 0; i < text.length; i++) {
299 const ch = text[i]!;
300 if (q) {
301 if (ch === "\\" && q === '"') i++;
302 else if (ch === q) q = null;
303 continue;
304 }
305 if (ch === '"' || ch === "'") q = ch;
306 else if (ch === "[" || ch === "{") depth++;
307 else if (ch === "]" || ch === "}") depth--;
308 else if (ch === "#" && /\s/.test(text[i - 1] ?? " ") && depth > 0) {
309 // comment inside a flow on this line: ignore rest of the line
310 const nl = text.indexOf("\n", i);
311 i = nl < 0 ? text.length : nl;
312 }
313 }
314 return depth <= 0;
315}
316
317class FlowParser {
318 i = 0;
319 constructor(private s: string) {}
320 ws(): void {
321 while (this.i < this.s.length && /\s/.test(this.s[this.i]!)) this.i++;
322 }
323 value(): unknown {
324 this.ws();
325 const c = this.s[this.i];
326 if (c === "[") {
327 this.i++;
328 const arr: unknown[] = [];
329 for (;;) {
330 this.ws();
331 if (this.s[this.i] === "]") { this.i++; break; }
332 arr.push(this.value());
333 this.ws();
334 if (this.s[this.i] === ",") this.i++;
335 else if (this.s[this.i] === "]") { this.i++; break; }
336 else if (this.i >= this.s.length) break;
337 }
338 return arr;
339 }
340 if (c === "{") {
341 this.i++;
342 const obj: Record<string, unknown> = {};
343 for (;;) {
344 this.ws();
345 if (this.s[this.i] === "}") { this.i++; break; }
346 const k = this.scalar(true);
347 this.ws();
348 let v: unknown = null;
349 if (this.s[this.i] === ":") {
350 this.i++;
351 v = this.value();
352 }
353 obj[String(k)] = v;
354 this.ws();
355 if (this.s[this.i] === ",") this.i++;
356 else if (this.s[this.i] === "}") { this.i++; break; }
357 else if (this.i >= this.s.length) break;
358 }
359 return obj;
360 }
361 return this.scalar(false);
362 }
363 scalar(isKey: boolean): unknown {
364 this.ws();
365 const c = this.s[this.i];
366 if (c === '"' || c === "'") {
367 const r = parseQuoted(this.s.slice(this.i));
368 this.i += r.end;
369 return r.value;
370 }
371 let j = this.i;
372 while (j < this.s.length) {
373 const ch = this.s[j]!;
374 if (ch === "," || ch === "]" || ch === "}") break;
375 if (ch === ":" && (isKey || /[\s,\]}]/.test(this.s[j + 1] ?? " "))) break;
376 j++;
377 }
378 const tok = this.s.slice(this.i, j).trim();
379 this.i = j;
380 return scalarFromToken(tok);
381 }
382}
383
384function scalarFromToken(tok: string): unknown {
385 const t = tok.trim();
386 if (t.startsWith('"') || t.startsWith("'")) return parseQuoted(t).value;
387 if (t === "" || t === "~" || t === "null" || t === "Null" || t === "NULL") return null;
388 if (/^(true|True|TRUE)$/.test(t)) return true;
389 if (/^(false|False|FALSE)$/.test(t)) return false;
390 if (/^[-+]?(0|[1-9][0-9]*)$/.test(t)) return Number(t);
391 if (/^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$/.test(t)) return Number(t);
392 return t;
393}