1/**
2 * Parses YAML configuration files.
3 *
4 * Supports YAML 1.2 block and flow collections, indentless sequence values,
5 * multiline plain and quoted scalars, block scalars, anchors, and aliases.
6 *
7 * Unsupported: compact nested block sequences (`- - value` on one line),
8 * multiline flow collections, explicit complex keys, tag/directive processing,
9 * recursive aliases, and document streams. Mapping keys are JavaScript strings.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14
15/*
16 * The behavior is based on `yaml` 2.9.0.
17 *
18 * Copyright Eemeli Aro <eemeli@gmail.com>
19 *
20 * Permission to use, copy, modify, and/or distribute this software for any
21 * purpose with or without fee is hereby granted, provided that the above
22 * copyright notice and this permission notice appear in all copies.
23 *
24 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
25 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
26 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
27 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
28 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
29 * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
30 * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
31 */
32
33type YamlRecord = Record<string, unknown>
34
35type Line = {
36 readonly raw: string
37 readonly text: string
38 readonly indent: number
39 readonly number: number
40}
41
42const hasOwn = Object.prototype.hasOwnProperty
43
44const setProperty = (record: YamlRecord, key: string, value: unknown): void => {
45 Object.defineProperty(record, key, {
46 configurable: true,
47 enumerable: true,
48 writable: true,
49 value
50 })
51}
52
53const closingQuote = (input: string, quote: string, start: number): number => {
54 for (let index = start; index < input.length; index++) {
55 const character = input[index]
56 if (quote === "\"" && character === "\\") {
57 index++
58 } else if (character === quote) {
59 if (quote === "'" && input[index + 1] === quote) index++
60 else return index
61 }
62 }
63 return -1
64}
65
66const findIndicator = (input: string, indicator: ":" | "#"): number => {
67 let depth = 0
68 let tokenStart = true
69 for (let index = 0; index < input.length; index++) {
70 const character = input[index]
71 if (tokenStart && (character === "'" || character === "\"")) {
72 index = closingQuote(input, character, index + 1)
73 if (index === -1) return -1
74 tokenStart = false
75 } else if (tokenStart && character === "&") {
76 while (index + 1 < input.length && !/[\s,[\]{}]/.test(input[index + 1])) index++
77 } else if (character === "#" && (index === 0 || /\s/.test(input[index - 1]))) {
78 return indicator === "#" ? index : -1
79 } else if (tokenStart && (character === "[" || character === "{")) {
80 depth++
81 } else if (depth > 0 && (character === "]" || character === "}")) {
82 depth--
83 tokenStart = false
84 } else if (depth > 0 && character === ",") {
85 tokenStart = true
86 } else if (character === ":" && (depth > 0 || input[index + 1] === undefined || /\s/.test(input[index + 1]))) {
87 if (depth === 0 && indicator === ":") return index
88 tokenStart = true
89 } else if (tokenStart && character === "-" && /\s/.test(input[index + 1] ?? "")) {
90 continue
91 } else if (!/\s/.test(character)) {
92 tokenStart = false
93 }
94 }
95 return -1
96}
97
98const stripComment = (input: string): string => {
99 const index = findIndicator(input, "#")
100 return index === -1 ? input : input.slice(0, index).trimEnd()
101}
102
103const mappingSeparator = (input: string): number => findIndicator(input, ":")
104
105const isSequenceEntry = (text: string): boolean => text === "-" || text.startsWith("- ")
106
107const foldLineBreak = (blankLines: number): string => blankLines === 0 ? " " : "\n".repeat(blankLines)
108
109const endsWithEscape = (text: string): boolean => /\\*$/.exec(text)![0].length % 2 === 1
110
111// Escaped trailing spaces are content, not folding whitespace.
112const trimFoldedEnd = (line: string, quote: string): string => {
113 const trailing = /[ \t]+$/.exec(line)
114 if (trailing === null) return line
115 const escaped = quote === "\"" && endsWithEscape(line.slice(0, trailing.index))
116 return line.slice(0, trailing.index + (escaped ? 1 : 0))
117}
118
119const parseDoubleQuoted = (input: string): string => {
120 if (!input.endsWith("\"") || input.length < 2) {
121 throw new SyntaxError("Unterminated double-quoted YAML string")
122 }
123 let output = ""
124 for (let index = 1; index < input.length - 1; index++) {
125 const character = input[index]
126 if (character !== "\\") {
127 output += character
128 continue
129 }
130 const escape = input[++index]
131 const escapes: Record<string, string> = {
132 "0": "\0",
133 a: "\x07",
134 b: "\b",
135 t: "\t",
136 n: "\n",
137 v: "\v",
138 f: "\f",
139 r: "\r",
140 e: "\x1b",
141 " ": " ",
142 "\"": "\"",
143 "/": "/",
144 "\\": "\\",
145 N: "\u0085",
146 _: "\u00a0",
147 L: "\u2028",
148 P: "\u2029"
149 }
150 if (hasOwn.call(escapes, escape)) {
151 output += escapes[escape]
152 continue
153 }
154 if (escape === "x" || escape === "u" || escape === "U") {
155 const length = escape === "x" ? 2 : escape === "u" ? 4 : 8
156 const hex = input.slice(index + 1, index + 1 + length)
157 if (!new RegExp(`^[0-9A-Fa-f]{${length}}$`).test(hex)) {
158 throw new SyntaxError("Invalid unicode escape in YAML string")
159 }
160 output += String.fromCodePoint(Number.parseInt(hex, 16))
161 index += length
162 continue
163 }
164 throw new SyntaxError(`Invalid YAML escape '\\${escape}'`)
165 }
166 return output
167}
168
169const parseScalar = (input: string): unknown => {
170 const value = input.trim()
171 if (value.length === 0) return null
172 if (value.startsWith("\"") && value.endsWith("\"")) {
173 return parseDoubleQuoted(value)
174 }
175 if (value.startsWith("'") && value.endsWith("'")) {
176 return value.slice(1, -1).replace(/''/g, "'")
177 }
178 if (/^(?:null|~)$/i.test(value)) return null
179 if (/^true$/i.test(value)) return true
180 if (/^false$/i.test(value)) return false
181 if (/^[+-]?\.inf$/i.test(value)) return value[0] === "-" ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY
182 if (/^\.nan$/i.test(value)) return Number.NaN
183
184 const normalized = value.replace(/_/g, "")
185 if (/^[+-]?0x[0-9a-f]+$/i.test(normalized)) {
186 const sign = normalized[0] === "-" ? -1 : 1
187 return sign * Number.parseInt(normalized.replace(/^[+-]?0x/i, ""), 16)
188 }
189 if (/^[+-]?0o[0-7]+$/i.test(normalized)) {
190 const sign = normalized[0] === "-" ? -1 : 1
191 return sign * Number.parseInt(normalized.replace(/^[+-]?0o/i, ""), 8)
192 }
193 if (
194 /^[+-]?(?:0|[1-9]\d*)$/.test(normalized) ||
195 /^[+-]?(?:(?:0|[1-9]\d*)?\.\d+|(?:0|[1-9]\d*)\.?\d*[eE][+-]?\d+)$/.test(normalized)
196 ) {
197 return Number(normalized)
198 }
199 return value
200}
201
202const parseKey = (input: string): string => {
203 const value = parseScalar(input)
204 return value === null || value === undefined ? "" : String(value)
205}
206
207class FlowParser {
208 private readonly input: string
209 private readonly anchors: ReadonlyMap<string, unknown>
210 private index = 0
211
212 constructor(input: string, anchors: ReadonlyMap<string, unknown>) {
213 this.input = input
214 this.anchors = anchors
215 }
216
217 parse(): unknown {
218 const value = this.parseValue()
219 this.skipWhitespace()
220 if (this.index !== this.input.length) {
221 this.fail("Unexpected flow collection content")
222 }
223 return value
224 }
225
226 private parseValue(): unknown {
227 this.skipWhitespace()
228 const character = this.peek()
229 if (character === "[") return this.parseSequence()
230 if (character === "{") return this.parseMapping()
231 if (character === "\"" || character === "'") return parseScalar(this.readQuoted(character))
232 if (character === "*") {
233 this.index++
234 const name = this.readUntil(/[,\]}\s]/)
235 if (!this.anchors.has(name)) this.fail(`Unknown alias '*${name}'`)
236 return this.anchors.get(name)
237 }
238 return parseScalar(this.readUntil(/[,\]}]/).trim())
239 }
240
241 private parseSequence(): Array<unknown> {
242 this.expect("[")
243 const output: Array<unknown> = []
244 this.skipWhitespace()
245 if (this.peek() === "]") {
246 this.index++
247 return output
248 }
249 while (true) {
250 output.push(this.parseValue())
251 this.skipWhitespace()
252 if (this.peek() === "]") {
253 this.index++
254 return output
255 }
256 this.expect(",")
257 this.skipWhitespace()
258 if (this.peek() === "]") {
259 this.index++
260 return output
261 }
262 }
263 }
264
265 private parseMapping(): YamlRecord {
266 this.expect("{")
267 const output: YamlRecord = {}
268 this.skipWhitespace()
269 if (this.peek() === "}") {
270 this.index++
271 return output
272 }
273 while (true) {
274 const key = this.parseKey()
275 this.skipWhitespace()
276 this.expect(":")
277 if (hasOwn.call(output, key)) this.fail(`Duplicate key '${key}'`)
278 setProperty(output, key, this.parseValue())
279 this.skipWhitespace()
280 if (this.peek() === "}") {
281 this.index++
282 return output
283 }
284 this.expect(",")
285 this.skipWhitespace()
286 if (this.peek() === "}") {
287 this.index++
288 return output
289 }
290 }
291 }
292
293 private parseKey(): string {
294 this.skipWhitespace()
295 const character = this.peek()
296 if (character === "\"" || character === "'") {
297 return parseKey(this.readQuoted(character))
298 }
299 return this.readUntil(/:/).trim()
300 }
301
302 private readQuoted(quote: string): string {
303 const start = this.index
304 const end = closingQuote(this.input, quote, start + 1)
305 if (end === -1) this.fail("Unterminated quoted scalar")
306 this.index = end + 1
307 return this.input.slice(start, this.index)
308 }
309
310 private readUntil(stop: RegExp): string {
311 const start = this.index
312 while (this.index < this.input.length && !stop.test(this.peek())) this.index++
313 return this.input.slice(start, this.index)
314 }
315
316 private skipWhitespace(): void {
317 while (/\s/.test(this.peek())) this.index++
318 }
319
320 private expect(character: string): void {
321 if (this.peek() !== character) this.fail(`Expected '${character}'`)
322 this.index++
323 }
324
325 private peek(): string {
326 return this.input[this.index] ?? ""
327 }
328
329 private fail(message: string): never {
330 throw new SyntaxError(`${message} at flow offset ${this.index}`)
331 }
332}
333
334class YamlParser {
335 private readonly lines: ReadonlyArray<Line>
336 private index = 0
337 private documentStarted = false
338 private readonly anchors = new Map<string, unknown>()
339
340 constructor(lines: ReadonlyArray<Line>) {
341 this.lines = lines
342 }
343
344 parse(): unknown {
345 this.skipIgnored()
346 if (this.index >= this.lines.length) return null
347 this.documentStarted = true
348 const value = this.parseNode(this.lines[this.index].indent)
349 this.skipIgnored()
350 if (this.index < this.lines.length) {
351 this.fail(this.lines[this.index], "Unexpected content")
352 }
353 return value
354 }
355
356 private parseNode(indent: number, parentIndent = indent - 1): unknown {
357 this.skipIgnored()
358 const line = this.lines[this.index]
359 if (line === undefined) return null
360 if (line.indent !== indent) this.fail(line, `Expected indentation of ${indent} spaces`)
361 if (isSequenceEntry(line.text)) return this.parseSequence(indent)
362 if (mappingSeparator(line.text) !== -1) return this.parseMapping(indent)
363 this.index++
364 return this.parseNodeValue(line.text, parentIndent)
365 }
366
367 private parseMapping(indent: number, output: YamlRecord = {}): YamlRecord {
368 while (true) {
369 this.skipIgnored()
370 const line = this.lines[this.index]
371 if (line === undefined || line.indent < indent) return output
372 if (line.indent > indent) this.fail(line, `Unexpected indentation of ${line.indent} spaces`)
373 const separator = mappingSeparator(line.text)
374 if (separator === -1) return output
375 this.index++
376 this.parseMappingEntry(output, line.text, separator, indent, line)
377 }
378 }
379
380 private parseMappingEntry(
381 output: YamlRecord,
382 text: string,
383 separator: number,
384 indent: number,
385 line: Line
386 ): void {
387 const key = parseKey(text.slice(0, separator).trim())
388 const rawValue = text.slice(separator + 1).trimStart()
389 const value = this.parseNodeValue(rawValue, indent, true)
390 if (hasOwn.call(output, key)) this.fail(line, `Duplicate key '${key}'`)
391 setProperty(output, key, value)
392 }
393
394 private parseSequence(indent: number): Array<unknown> {
395 const output: Array<unknown> = []
396 while (true) {
397 this.skipIgnored()
398 const line = this.lines[this.index]
399 if (line === undefined || line.indent < indent) return output
400 if (line.indent > indent) this.fail(line, `Unexpected indentation of ${line.indent} spaces`)
401 if (!isSequenceEntry(line.text)) return output
402 this.index++
403 const item = line.text.slice(1).trimStart()
404 if (isSequenceEntry(item)) this.fail(line, "Compact nested block sequences are not supported")
405 const separator = mappingSeparator(item)
406 if (separator === -1) {
407 output.push(this.parseNodeValue(item, indent))
408 } else {
409 const mapping: YamlRecord = {}
410 this.parseMappingEntry(mapping, item, separator, indent + 2, line)
411 output.push(this.parseMapping(indent + 2, mapping))
412 }
413 }
414 }
415
416 private parseNodeValue(rawValue: string, parentIndent: number, allowIndentless = false): unknown {
417 const anchor = /^&([^\s,[\]{}]+)(?:\s+(.*))?$/.exec(rawValue)
418 if (anchor === null) return this.parseValue(rawValue, parentIndent, allowIndentless)
419 const value = this.parseValue(anchor[2] ?? "", parentIndent, allowIndentless)
420 this.anchors.set(anchor[1], value)
421 return value
422 }
423
424 private parseValue(value: string, parentIndent: number, allowIndentless: boolean): unknown {
425 const text = value.trimEnd()
426 if (text.length === 0) {
427 this.skipIgnored()
428 const next = this.lines[this.index]
429 if (next === undefined) return null
430 if (next.indent > parentIndent) return this.parseNode(next.indent, parentIndent)
431 if (allowIndentless && next.indent === parentIndent && isSequenceEntry(next.text)) {
432 return this.parseSequence(parentIndent)
433 }
434 return null
435 }
436 if (/^[|>](?:[1-9]?[+-]?|[+-]?[1-9]?)$/.test(text)) return this.parseBlockScalar(text, parentIndent)
437 // Keep escaped trailing whitespace in quoted values.
438 if (value.startsWith("\"") || value.startsWith("'")) return this.parseQuotedValue(value, parentIndent)
439 if (text.startsWith("*")) {
440 const name = text.slice(1).trim()
441 if (!this.anchors.has(name)) throw new SyntaxError(`Unknown YAML alias '*${name}'`)
442 return this.anchors.get(name)
443 }
444 if (text.startsWith("[") || text.startsWith("{")) return new FlowParser(text, this.anchors).parse()
445 return this.parsePlainValue(text, parentIndent)
446 }
447
448 private parsePlainValue(value: string, parentIndent: number): unknown {
449 const first = this.lines[this.index - 1]
450 if (/:(?:\s|$)/.test(value)) this.fail(first, "Invalid colon in plain scalar")
451 let output = value
452 let terminated = findIndicator(first.raw, "#") !== -1
453 while (!terminated) {
454 const blankLines = this.skipBlankLines()
455 const next = this.lines[this.index]
456 if (next === undefined || next.indent <= parentIndent) break
457 // Quotes in plain continuations are literal.
458 const content = next.raw.slice(next.indent)
459 const comment = content.search(/(?:^|[ \t])#/)
460 const text = (comment === -1 ? content : content.slice(0, comment)).trimEnd()
461 if (text.length === 0 || (next.indent === 0 && /^(?:---|\.\.\.)(?:\s|$)/.test(text))) break
462 if (/:(?:\s|$)/.test(text)) this.fail(next, "Invalid colon in plain scalar")
463 output += foldLineBreak(blankLines) + text
464 this.index++
465 terminated = comment !== -1
466 }
467 return parseScalar(output)
468 }
469
470 private parseQuotedValue(value: string, parentIndent: number): unknown {
471 const quote = value[0]
472 let output = quote
473 let line = value.slice(1)
474 while (true) {
475 const end = closingQuote(line, quote, 0)
476 if (end !== -1) {
477 const rest = line.slice(end + 1)
478 if (rest.trim().length > 0 && !/^[ \t]+#/.test(rest)) {
479 throw new SyntaxError("Unexpected content after quoted YAML scalar")
480 }
481 return parseScalar(output + line.slice(0, end + 1))
482 }
483 if (quote === "\"" && endsWithEscape(line)) {
484 output += line.slice(0, -1)
485 } else {
486 output += trimFoldedEnd(line, quote) + foldLineBreak(this.skipBlankLines())
487 }
488 const next = this.lines[this.index++]
489 if (next === undefined) throw new SyntaxError("Unterminated quoted YAML scalar")
490 if (next.raw.trim().length > 0 && next.indent <= parentIndent) {
491 this.fail(next, "Invalid quoted scalar indentation")
492 }
493 line = next.raw.trimStart()
494 }
495 }
496
497 private parseBlockScalar(indicator: string, parentIndent: number): string {
498 const style = indicator[0]
499 const chomp = indicator.includes("-") ? "strip" : indicator.includes("+") ? "keep" : "clip"
500 const explicitIndent = Number.parseInt(indicator.replace(/[^1-9]/g, ""), 10)
501 const start = this.index
502 let end = start
503 let contentIndent = Number.isNaN(explicitIndent) ? Number.POSITIVE_INFINITY : parentIndent + explicitIndent
504
505 while (end < this.lines.length) {
506 const line = this.lines[end]
507 if (line.raw.trim().length !== 0 && line.indent <= parentIndent) break
508 if (line.raw.trim().length !== 0) contentIndent = Math.min(contentIndent, line.indent)
509 end++
510 }
511 if (contentIndent === Number.POSITIVE_INFINITY) contentIndent = parentIndent + 1
512
513 const content: Array<string> = []
514 for (let index = start; index < end; index++) {
515 const line = this.lines[index]
516 if (line.raw.trim().length === 0) {
517 content.push("")
518 } else if (line.indent < contentIndent) {
519 this.fail(line, `Expected block scalar indentation of ${contentIndent} spaces`)
520 } else {
521 content.push(line.raw.slice(contentIndent))
522 }
523 }
524 this.index = end
525
526 let output = ""
527 if (style === "|") {
528 output = content.join("\n")
529 } else {
530 let blankLines = 0
531 let previousMoreIndented: boolean | undefined
532 for (const line of content) {
533 if (line.length === 0) {
534 blankLines++
535 } else {
536 const moreIndented = line.startsWith(" ")
537 const hardBreak = moreIndented || previousMoreIndented === true
538 if (previousMoreIndented === undefined) output += "\n".repeat(blankLines)
539 else if (blankLines === 0) output += hardBreak ? "\n" : " "
540 else output += "\n".repeat(blankLines + (hardBreak ? 1 : 0))
541 output += line
542 blankLines = 0
543 previousMoreIndented = moreIndented
544 }
545 }
546 output += "\n".repeat(blankLines)
547 }
548 if (chomp === "keep") return output.endsWith("\n") ? output : `${output}\n`
549 output = output.replace(/\n+$/, "")
550 return chomp === "strip" ? output : `${output}\n`
551 }
552
553 private skipBlankLines(): number {
554 const start = this.index
555 while (this.index < this.lines.length && this.lines[this.index].raw.trim().length === 0) this.index++
556 return this.index - start
557 }
558
559 private skipIgnored(): void {
560 while (this.index < this.lines.length) {
561 const line = this.lines[this.index]
562 const text = line.text.trimEnd()
563 if (text.length === 0 || text.startsWith("%")) {
564 this.index++
565 } else if (line.indent === 0 && text === "---") {
566 if (this.documentStarted) this.fail(line, "Multiple YAML documents are not supported")
567 this.documentStarted = true
568 this.index++
569 } else if (line.indent === 0 && text === "...") {
570 this.index++
571 while (this.index < this.lines.length && this.lines[this.index].text.trimEnd().length === 0) this.index++
572 if (this.index < this.lines.length) {
573 this.fail(this.lines[this.index], "Multiple YAML documents are not supported")
574 }
575 } else {
576 return
577 }
578 }
579 }
580
581 private fail(line: Line, message: string): never {
582 throw new SyntaxError(`${message} at line ${line.number}`)
583 }
584}
585
586/**
587 * Parses one YAML document into JavaScript values.
588 *
589 * **Details**
590 *
591 * The core YAML 1.2 scalar schema is used, so booleans, nulls, and numbers are
592 * decoded while date-like values remain strings.
593 *
594 * @stability unstable
595 * @category decoding
596 * @since 4.0.0
597 */
598export const parse = (input: string): unknown => {
599 const source = input.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")
600 const lines = source.split("\n").map((raw, index): Line => {
601 const indentation = /^( *)/.exec(raw)![1].length
602 if (raw.slice(0, indentation + 1).includes("\t")) {
603 throw new SyntaxError(`Tabs cannot be used for YAML indentation at line ${index + 1}`)
604 }
605 return {
606 raw,
607 text: stripComment(raw.slice(indentation)),
608 indent: indentation,
609 number: index + 1
610 }
611 })
612 return new YamlParser(lines).parse()
613}