/** * Parses TOML configuration files. * * The implementation covers the TOML values and table forms used by Effect's * configuration-file CLI primitive while avoiding a generated parser runtime. * * @stability unstable * @since 4.0.0 */ /* * The behavior is based on `toml` 4.1.2. * * Copyright (c) 2012 Michelle Tilley * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ type Table = Record const hasOwn = Object.prototype.hasOwnProperty const makeTable = (): Table => Object.create(null) const isTable = (value: unknown): value is Table => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) class TomlParser { readonly root = makeTable() private readonly input: string private current = this.root private index = 0 private line = 1 private column = 1 private readonly explicitTables = new Set() constructor(input: string) { this.input = input } parse(): Table { while (true) { this.skipDocumentWhitespace() if (this.done) { return this.root } if (this.peek() === "[") { this.parseHeader() } else { const keys = this.parseKeyPath("=") this.skipInlineWhitespace() this.expect("=") this.skipInlineWhitespace() this.assign(this.current, keys, this.parseValue()) this.finishStatement() } } } private parseHeader(): void { this.expect("[") const array = this.peek() === "[" if (array) { this.advance() } this.skipInlineWhitespace() const path = this.parseKeyPath("]") this.skipInlineWhitespace() this.expect("]") if (array) { this.expect("]") } this.finishStatement() const table = this.resolveTable(path, array) if (!array && this.explicitTables.has(table)) { this.fail(`Cannot redefine table '${path.join(".")}'`) } if (!array) { this.explicitTables.add(table) } this.current = table } private resolveTable(path: ReadonlyArray, array: boolean): Table { let table = this.root for (let index = 0; index < path.length; index++) { const key = path[index] const last = index === path.length - 1 let value = table[key] if (last && array) { if (value === undefined) { value = [] table[key] = value } if (!Array.isArray(value)) { this.fail(`Cannot redefine existing key '${path.slice(0, index + 1).join(".")}'`) } const next = makeTable() value.push(next) return next } if (value === undefined) { value = makeTable() table[key] = value } if (Array.isArray(value)) { value = value[value.length - 1] } if (!isTable(value)) { this.fail(`Cannot redefine existing key '${path.slice(0, index + 1).join(".")}'`) } table = value } return table } private assign(target: Table, keys: ReadonlyArray, value: unknown): void { let table = target for (let index = 0; index < keys.length - 1; index++) { const key = keys[index] const existing = table[key] if (existing === undefined) { const child = makeTable() table[key] = child table = child } else if (isTable(existing)) { table = existing } else { this.fail(`Cannot redefine existing key '${keys.slice(0, index + 1).join(".")}'`) } } const key = keys[keys.length - 1] if (hasOwn.call(table, key)) { this.fail(`Cannot redefine existing key '${keys.join(".")}'`) } table[key] = value } private parseKeyPath(stop: "=" | "]"): Array { const keys: Array = [] while (true) { this.skipInlineWhitespace() const character = this.peek() let key: string if (character === "\"") { key = this.parseBasicString(false) } else if (character === "'") { key = this.parseLiteralString(false) } else { const start = this.index while (/[A-Za-z0-9_-]/.test(this.peek())) { this.advance() } key = this.input.slice(start, this.index) if (key.length === 0) { this.fail("Expected a key") } } keys.push(key) this.skipInlineWhitespace() if (this.peek() === ".") { this.advance() continue } if (this.peek() !== stop) { this.fail(`Expected '${stop}'`) } return keys } } private parseValue(): unknown { const character = this.peek() if (character === "\"") { return this.parseBasicString(this.input.startsWith("\"\"\"", this.index)) } if (character === "'") { return this.parseLiteralString(this.input.startsWith("'''", this.index)) } if (character === "[") { return this.parseArray() } if (character === "{") { return this.parseInlineTable() } const spaceSeparatedDateTime = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)/ .exec(this.input.slice(this.index)) if (spaceSeparatedDateTime !== null) { this.advance(spaceSeparatedDateTime[0].length) return this.parseDate(`${spaceSeparatedDateTime[1]}T${spaceSeparatedDateTime[2]}`)! } const start = this.index while (!this.done && !/[\s,#\]}]/.test(this.peek())) { this.advance() } const token = this.input.slice(start, this.index) if (token === "true") return true if (token === "false") return false if (token.length === 0) this.fail("Expected a value") const date = this.parseDate(token) if (date !== undefined) { return date } const number = this.parseNumber(token) if (number !== undefined) { return number } this.fail(`Invalid value '${token}'`) } private parseDate(token: string): Date | string | undefined { const date = "\\d{4}-\\d{2}-\\d{2}" const time = "\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?" if (new RegExp(`^${date}[Tt]${time}(?:[Zz]|[+-]\\d{2}:\\d{2})$`).test(token)) { const value = new Date(token.replace("t", "T").replace("z", "Z")) if (Number.isNaN(value.getTime())) { this.fail(`Invalid date-time '${token}'`) } return value } if ( new RegExp(`^${date}[Tt]${time}$`).test(token) || new RegExp(`^${date}$`).test(token) || new RegExp(`^${time}$`).test(token) ) { return token.replace("t", "T") } return undefined } private parseNumber(token: string): number | undefined { const normalized = token.replace(/_/g, "") if (/^[+-]?(?:inf|nan)$/.test(token)) { if (normalized.endsWith("nan")) return Number.NaN return normalized[0] === "-" ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY } if (/^0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*$/.test(token)) { return Number.parseInt(normalized.slice(2), 16) } if (/^0o[0-7](?:_?[0-7])*$/.test(token)) { return Number.parseInt(normalized.slice(2), 8) } if (/^0b[01](?:_?[01])*$/.test(token)) { return Number.parseInt(normalized.slice(2), 2) } if (/^[+-]?(?:0|[1-9](?:_?\d)*)$/.test(token)) { return Number(normalized) } if ( /^[+-]?(?:(?:0|[1-9](?:_?\d)*)\.\d(?:_?\d)*(?:[eE][+-]?\d(?:_?\d)*)?|(?:0|[1-9](?:_?\d)*)[eE][+-]?\d(?:_?\d)*)$/ .test( token ) ) { return Number(normalized) } return undefined } private parseBasicString(multiline: boolean): string { this.expect("\"") if (multiline) { this.expect("\"") this.expect("\"") this.advance(this.input.startsWith("\r\n", this.index) ? 2 : this.peek() === "\n" ? 1 : 0) } let output = "" while (!this.done) { if (multiline && this.input.startsWith("\"\"\"", this.index)) { this.advance(3) return output } const character = this.peek() if (!multiline && character === "\"") { this.advance() return output } if (!multiline && (character === "\n" || character === "\r")) { this.fail("Basic strings cannot contain newlines") } if (character !== "\\") { output += character this.advance() continue } this.advance() if (multiline && /[ \t\r\n]/.test(this.peek())) { while (/[ \t]/.test(this.peek())) this.advance() if (this.peek() !== "\n" && this.peek() !== "\r") { this.fail("Invalid multiline string continuation") } while (/[ \t\r\n]/.test(this.peek())) this.advance() continue } const escape = this.peek() this.advance() const escapes: Record = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", "\"": "\"", "\\": "\\" } if (hasOwn.call(escapes, escape)) { output += escapes[escape] } else if (escape === "u" || escape === "U") { const length = escape === "u" ? 4 : 8 const hex = this.input.slice(this.index, this.index + length) if (!new RegExp(`^[0-9A-Fa-f]{${length}}$`).test(hex)) { this.fail("Invalid unicode escape") } output += String.fromCodePoint(Number.parseInt(hex, 16)) this.advance(length) } else { this.fail(`Invalid escape '\\${escape}'`) } } this.fail("Unterminated basic string") } private parseLiteralString(multiline: boolean): string { this.expect("'") if (multiline) { this.expect("'") this.expect("'") this.advance(this.input.startsWith("\r\n", this.index) ? 2 : this.peek() === "\n" ? 1 : 0) } const start = this.index while (!this.done) { if (multiline && this.input.startsWith("'''", this.index)) { const output = this.input.slice(start, this.index) this.advance(3) return output } if (!multiline && this.peek() === "'") { const output = this.input.slice(start, this.index) this.advance() return output } if (!multiline && (this.peek() === "\n" || this.peek() === "\r")) { this.fail("Literal strings cannot contain newlines") } this.advance() } this.fail("Unterminated literal string") } private parseArray(): Array { this.expect("[") const output: Array = [] while (true) { this.skipArrayWhitespace() if (this.peek() === "]") { this.advance() return output } output.push(this.parseValue()) this.skipArrayWhitespace() if (this.peek() === "]") { this.advance() return output } this.expect(",") } } private parseInlineTable(): Table { this.expect("{") const output = makeTable() this.skipInlineWhitespace() if (this.peek() === "}") { this.advance() return output } while (true) { const keys = this.parseKeyPath("=") this.skipInlineWhitespace() this.expect("=") this.skipInlineWhitespace() this.assign(output, keys, this.parseValue()) this.skipInlineWhitespace() if (this.peek() === "}") { this.advance() return output } this.expect(",") this.skipInlineWhitespace() if (this.peek() === "}") { this.fail("Inline tables cannot end with a trailing comma") } } } private skipDocumentWhitespace(): void { while (!this.done) { if (/[ \t\r\n]/.test(this.peek())) { this.advance() } else if (this.peek() === "#") { this.skipComment() } else { return } } } private skipInlineWhitespace(): void { while (this.peek() === " " || this.peek() === "\t") { this.advance() } } private skipArrayWhitespace(): void { while (!this.done) { if (/[ \t\r\n]/.test(this.peek())) { this.advance() } else if (this.peek() === "#") { this.skipComment() } else { return } } } private finishStatement(): void { this.skipInlineWhitespace() if (this.peek() === "#") { this.skipComment() } if (!this.done && this.peek() !== "\n" && this.peek() !== "\r") { this.fail("Expected the end of the line") } } private skipComment(): void { while (!this.done && this.peek() !== "\n") { this.advance() } } private expect(character: string): void { if (this.peek() !== character) { this.fail(`Expected '${character}'`) } this.advance() } private advance(count = 1): void { for (let offset = 0; offset < count; offset++) { if (this.input[this.index] === "\n") { this.line++ this.column = 1 } else { this.column++ } this.index++ } } private peek(): string { return this.input[this.index] ?? "" } private get done(): boolean { return this.index >= this.input.length } private fail(message: string): never { throw new SyntaxError(`${message} at line ${this.line}, column ${this.column}`) } } /** * Parses a TOML document into a null-prototype record. * * **Details** * * Offset date-times are represented by `Date`, matching the package this * parser replaces. Local dates and times remain strings. * * @stability unstable * @category decoding * @since 4.0.0 */ export const parse = (input: string): Record => new TomlParser(input.replace(/^\uFEFF/, "")).parse()