| 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 | |
| 8 | interface Line { |
| 9 | indent: number; |
| 10 | text: string; // without leading indentation |
| 11 | raw: string; |
| 12 | } |
| 13 | |
| 14 | const KEY_RE = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s#'"\-?:,\[\]{}][^#]*?|-[^\s][^#]*?|\?[^\s][^#]*?)\s*:(?=\s|$)/; |
| 15 | |
| 16 | export class YamlError extends Error {} |
| 17 | |
| 18 | export 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 | |
| 30 | function isBlank(l: Line): boolean { |
| 31 | const t = l.text.trim(); |
| 32 | return t === "" || t.startsWith("#"); |
| 33 | } |
| 34 | |
| 35 | function isSeqItem(text: string): boolean { |
| 36 | return text === "-" || text.startsWith("- ") || text.startsWith("-\t"); |
| 37 | } |
| 38 | |
| 39 | class 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 | |
| 236 | function stripComment(s: string): string { |
| 237 | const i = s.search(/(^|\s)#/); |
| 238 | return i < 0 ? s : s.slice(0, i); |
| 239 | } |
| 240 | |
| 241 | function quoteClosed(text: string): boolean { |
| 242 | try { |
| 243 | parseQuoted(text); |
| 244 | return true; |
| 245 | } catch { |
| 246 | return false; |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | function 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 | |
| 295 | function 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 | |
| 317 | class 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 | |
| 384 | function 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 | } |