1/**
2 * Parses TOML configuration files.
3 *
4 * The implementation covers the TOML values and table forms used by Effect's
5 * configuration-file CLI primitive while avoiding a generated parser runtime.
6 *
7 * @stability unstable
8 * @since 4.0.0
9 */
10
11/*
12 * The behavior is based on `toml` 4.1.2.
13 *
14 * Copyright (c) 2012 Michelle Tilley
15 *
16 * Permission is hereby granted, free of charge, to any person obtaining a copy
17 * of this software and associated documentation files (the "Software"), to deal
18 * in the Software without restriction, including without limitation the rights
19 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20 * copies of the Software, and to permit persons to whom the Software is
21 * furnished to do so, subject to the following conditions:
22 *
23 * The above copyright notice and this permission notice shall be included in
24 * all copies or substantial portions of the Software.
25 *
26 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32 * SOFTWARE.
33 */
34
35type Table = Record<string, unknown>
36
37const hasOwn = Object.prototype.hasOwnProperty
38const makeTable = (): Table => Object.create(null)
39const isTable = (value: unknown): value is Table =>
40 typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)
41
42class TomlParser {
43 readonly root = makeTable()
44 private readonly input: string
45 private current = this.root
46 private index = 0
47 private line = 1
48 private column = 1
49 private readonly explicitTables = new Set<Table>()
50
51 constructor(input: string) {
52 this.input = input
53 }
54
55 parse(): Table {
56 while (true) {
57 this.skipDocumentWhitespace()
58 if (this.done) {
59 return this.root
60 }
61 if (this.peek() === "[") {
62 this.parseHeader()
63 } else {
64 const keys = this.parseKeyPath("=")
65 this.skipInlineWhitespace()
66 this.expect("=")
67 this.skipInlineWhitespace()
68 this.assign(this.current, keys, this.parseValue())
69 this.finishStatement()
70 }
71 }
72 }
73
74 private parseHeader(): void {
75 this.expect("[")
76 const array = this.peek() === "["
77 if (array) {
78 this.advance()
79 }
80 this.skipInlineWhitespace()
81 const path = this.parseKeyPath("]")
82 this.skipInlineWhitespace()
83 this.expect("]")
84 if (array) {
85 this.expect("]")
86 }
87 this.finishStatement()
88
89 const table = this.resolveTable(path, array)
90 if (!array && this.explicitTables.has(table)) {
91 this.fail(`Cannot redefine table '${path.join(".")}'`)
92 }
93 if (!array) {
94 this.explicitTables.add(table)
95 }
96 this.current = table
97 }
98
99 private resolveTable(path: ReadonlyArray<string>, array: boolean): Table {
100 let table = this.root
101 for (let index = 0; index < path.length; index++) {
102 const key = path[index]
103 const last = index === path.length - 1
104 let value = table[key]
105
106 if (last && array) {
107 if (value === undefined) {
108 value = []
109 table[key] = value
110 }
111 if (!Array.isArray(value)) {
112 this.fail(`Cannot redefine existing key '${path.slice(0, index + 1).join(".")}'`)
113 }
114 const next = makeTable()
115 value.push(next)
116 return next
117 }
118
119 if (value === undefined) {
120 value = makeTable()
121 table[key] = value
122 }
123 if (Array.isArray(value)) {
124 value = value[value.length - 1]
125 }
126 if (!isTable(value)) {
127 this.fail(`Cannot redefine existing key '${path.slice(0, index + 1).join(".")}'`)
128 }
129 table = value
130 }
131 return table
132 }
133
134 private assign(target: Table, keys: ReadonlyArray<string>, value: unknown): void {
135 let table = target
136 for (let index = 0; index < keys.length - 1; index++) {
137 const key = keys[index]
138 const existing = table[key]
139 if (existing === undefined) {
140 const child = makeTable()
141 table[key] = child
142 table = child
143 } else if (isTable(existing)) {
144 table = existing
145 } else {
146 this.fail(`Cannot redefine existing key '${keys.slice(0, index + 1).join(".")}'`)
147 }
148 }
149 const key = keys[keys.length - 1]
150 if (hasOwn.call(table, key)) {
151 this.fail(`Cannot redefine existing key '${keys.join(".")}'`)
152 }
153 table[key] = value
154 }
155
156 private parseKeyPath(stop: "=" | "]"): Array<string> {
157 const keys: Array<string> = []
158 while (true) {
159 this.skipInlineWhitespace()
160 const character = this.peek()
161 let key: string
162 if (character === "\"") {
163 key = this.parseBasicString(false)
164 } else if (character === "'") {
165 key = this.parseLiteralString(false)
166 } else {
167 const start = this.index
168 while (/[A-Za-z0-9_-]/.test(this.peek())) {
169 this.advance()
170 }
171 key = this.input.slice(start, this.index)
172 }
173 if (key.length === 0) {
174 this.fail("Expected a key")
175 }
176 keys.push(key)
177 this.skipInlineWhitespace()
178 if (this.peek() === ".") {
179 this.advance()
180 continue
181 }
182 if (this.peek() !== stop) {
183 this.fail(`Expected '${stop}'`)
184 }
185 return keys
186 }
187 }
188
189 private parseValue(): unknown {
190 const character = this.peek()
191 if (character === "\"") {
192 return this.parseBasicString(this.input.startsWith("\"\"\"", this.index))
193 }
194 if (character === "'") {
195 return this.parseLiteralString(this.input.startsWith("'''", this.index))
196 }
197 if (character === "[") {
198 return this.parseArray()
199 }
200 if (character === "{") {
201 return this.parseInlineTable()
202 }
203
204 const spaceSeparatedDateTime = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)/
205 .exec(this.input.slice(this.index))
206 if (spaceSeparatedDateTime !== null) {
207 this.advance(spaceSeparatedDateTime[0].length)
208 return this.parseDate(`${spaceSeparatedDateTime[1]}T${spaceSeparatedDateTime[2]}`)!
209 }
210
211 const start = this.index
212 while (!this.done && !/[\s,#\]}]/.test(this.peek())) {
213 this.advance()
214 }
215 const token = this.input.slice(start, this.index)
216 if (token === "true") return true
217 if (token === "false") return false
218 if (token.length === 0) this.fail("Expected a value")
219
220 const date = this.parseDate(token)
221 if (date !== undefined) {
222 return date
223 }
224
225 const number = this.parseNumber(token)
226 if (number !== undefined) {
227 return number
228 }
229 this.fail(`Invalid value '${token}'`)
230 }
231
232 private parseDate(token: string): Date | string | undefined {
233 const date = "\\d{4}-\\d{2}-\\d{2}"
234 const time = "\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?"
235 if (new RegExp(`^${date}[Tt]${time}(?:[Zz]|[+-]\\d{2}:\\d{2})$`).test(token)) {
236 const value = new Date(token.replace("t", "T").replace("z", "Z"))
237 if (Number.isNaN(value.getTime())) {
238 this.fail(`Invalid date-time '${token}'`)
239 }
240 return value
241 }
242 if (
243 new RegExp(`^${date}[Tt]${time}$`).test(token) ||
244 new RegExp(`^${date}$`).test(token) ||
245 new RegExp(`^${time}$`).test(token)
246 ) {
247 return token.replace("t", "T")
248 }
249 return undefined
250 }
251
252 private parseNumber(token: string): number | undefined {
253 const normalized = token.replace(/_/g, "")
254 if (/^[+-]?(?:inf|nan)$/.test(token)) {
255 if (normalized.endsWith("nan")) return Number.NaN
256 return normalized[0] === "-" ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY
257 }
258 if (/^0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*$/.test(token)) {
259 return Number.parseInt(normalized.slice(2), 16)
260 }
261 if (/^0o[0-7](?:_?[0-7])*$/.test(token)) {
262 return Number.parseInt(normalized.slice(2), 8)
263 }
264 if (/^0b[01](?:_?[01])*$/.test(token)) {
265 return Number.parseInt(normalized.slice(2), 2)
266 }
267 if (/^[+-]?(?:0|[1-9](?:_?\d)*)$/.test(token)) {
268 return Number(normalized)
269 }
270 if (
271 /^[+-]?(?:(?:0|[1-9](?:_?\d)*)\.\d(?:_?\d)*(?:[eE][+-]?\d(?:_?\d)*)?|(?:0|[1-9](?:_?\d)*)[eE][+-]?\d(?:_?\d)*)$/
272 .test(
273 token
274 )
275 ) {
276 return Number(normalized)
277 }
278 return undefined
279 }
280
281 private parseBasicString(multiline: boolean): string {
282 this.expect("\"")
283 if (multiline) {
284 this.expect("\"")
285 this.expect("\"")
286 if (this.peek() === "\n") this.advance()
287 }
288 let output = ""
289 while (!this.done) {
290 if (multiline && this.input.startsWith("\"\"\"", this.index)) {
291 this.advance(3)
292 return output
293 }
294 const character = this.peek()
295 if (!multiline && character === "\"") {
296 this.advance()
297 return output
298 }
299 if (!multiline && (character === "\n" || character === "\r")) {
300 this.fail("Basic strings cannot contain newlines")
301 }
302 if (character !== "\\") {
303 output += character
304 this.advance()
305 continue
306 }
307
308 this.advance()
309 if (multiline && /[ \t\r\n]/.test(this.peek())) {
310 while (/[ \t]/.test(this.peek())) this.advance()
311 if (this.peek() !== "\n" && this.peek() !== "\r") {
312 this.fail("Invalid multiline string continuation")
313 }
314 while (/[ \t\r\n]/.test(this.peek())) this.advance()
315 continue
316 }
317 const escape = this.peek()
318 this.advance()
319 const escapes: Record<string, string> = {
320 b: "\b",
321 t: "\t",
322 n: "\n",
323 f: "\f",
324 r: "\r",
325 "\"": "\"",
326 "\\": "\\"
327 }
328 if (hasOwn.call(escapes, escape)) {
329 output += escapes[escape]
330 } else if (escape === "u" || escape === "U") {
331 const length = escape === "u" ? 4 : 8
332 const hex = this.input.slice(this.index, this.index + length)
333 if (!new RegExp(`^[0-9A-Fa-f]{${length}}$`).test(hex)) {
334 this.fail("Invalid unicode escape")
335 }
336 output += String.fromCodePoint(Number.parseInt(hex, 16))
337 this.advance(length)
338 } else {
339 this.fail(`Invalid escape '\\${escape}'`)
340 }
341 }
342 this.fail("Unterminated basic string")
343 }
344
345 private parseLiteralString(multiline: boolean): string {
346 this.expect("'")
347 if (multiline) {
348 this.expect("'")
349 this.expect("'")
350 if (this.peek() === "\n") this.advance()
351 }
352 const start = this.index
353 while (!this.done) {
354 if (multiline && this.input.startsWith("'''", this.index)) {
355 const output = this.input.slice(start, this.index)
356 this.advance(3)
357 return output
358 }
359 if (!multiline && this.peek() === "'") {
360 const output = this.input.slice(start, this.index)
361 this.advance()
362 return output
363 }
364 if (!multiline && (this.peek() === "\n" || this.peek() === "\r")) {
365 this.fail("Literal strings cannot contain newlines")
366 }
367 this.advance()
368 }
369 this.fail("Unterminated literal string")
370 }
371
372 private parseArray(): Array<unknown> {
373 this.expect("[")
374 const output: Array<unknown> = []
375 while (true) {
376 this.skipArrayWhitespace()
377 if (this.peek() === "]") {
378 this.advance()
379 return output
380 }
381 output.push(this.parseValue())
382 this.skipArrayWhitespace()
383 if (this.peek() === "]") {
384 this.advance()
385 return output
386 }
387 this.expect(",")
388 }
389 }
390
391 private parseInlineTable(): Table {
392 this.expect("{")
393 const output = makeTable()
394 this.skipInlineWhitespace()
395 if (this.peek() === "}") {
396 this.advance()
397 return output
398 }
399 while (true) {
400 const keys = this.parseKeyPath("=")
401 this.skipInlineWhitespace()
402 this.expect("=")
403 this.skipInlineWhitespace()
404 this.assign(output, keys, this.parseValue())
405 this.skipInlineWhitespace()
406 if (this.peek() === "}") {
407 this.advance()
408 return output
409 }
410 this.expect(",")
411 this.skipInlineWhitespace()
412 if (this.peek() === "}") {
413 this.fail("Inline tables cannot end with a trailing comma")
414 }
415 }
416 }
417
418 private skipDocumentWhitespace(): void {
419 while (!this.done) {
420 if (/[ \t\r\n]/.test(this.peek())) {
421 this.advance()
422 } else if (this.peek() === "#") {
423 this.skipComment()
424 } else {
425 return
426 }
427 }
428 }
429
430 private skipInlineWhitespace(): void {
431 while (this.peek() === " " || this.peek() === "\t") {
432 this.advance()
433 }
434 }
435
436 private skipArrayWhitespace(): void {
437 while (!this.done) {
438 if (/[ \t\r\n]/.test(this.peek())) {
439 this.advance()
440 } else if (this.peek() === "#") {
441 this.skipComment()
442 } else {
443 return
444 }
445 }
446 }
447
448 private finishStatement(): void {
449 this.skipInlineWhitespace()
450 if (this.peek() === "#") {
451 this.skipComment()
452 }
453 if (!this.done && this.peek() !== "\n" && this.peek() !== "\r") {
454 this.fail("Expected the end of the line")
455 }
456 }
457
458 private skipComment(): void {
459 while (!this.done && this.peek() !== "\n") {
460 this.advance()
461 }
462 }
463
464 private expect(character: string): void {
465 if (this.peek() !== character) {
466 this.fail(`Expected '${character}'`)
467 }
468 this.advance()
469 }
470
471 private advance(count = 1): void {
472 for (let offset = 0; offset < count; offset++) {
473 if (this.input[this.index] === "\n") {
474 this.line++
475 this.column = 1
476 } else {
477 this.column++
478 }
479 this.index++
480 }
481 }
482
483 private peek(): string {
484 return this.input[this.index] ?? ""
485 }
486
487 private get done(): boolean {
488 return this.index >= this.input.length
489 }
490
491 private fail(message: string): never {
492 throw new SyntaxError(`${message} at line ${this.line}, column ${this.column}`)
493 }
494}
495
496/**
497 * Parses a TOML document into a null-prototype record.
498 *
499 * **Details**
500 *
501 * Offset date-times are represented by `Date`, matching the package this
502 * parser replaces. Local dates and times remain strings.
503 *
504 * @stability unstable
505 * @category decoding
506 * @since 4.0.0
507 */
508export const parse = (input: string): Record<string, unknown> => new TomlParser(input.replace(/^\uFEFF/, "")).parse()