// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. import { deepAssign } from "../_util/deep_assign.ts"; import { assert } from "../_util/assert.ts"; class TOMLError extends Error {} class KeyValuePair { constructor(public key: string, public value: unknown) {} } class ParserGroup { arrValues: unknown[] = []; objValues: Record = {}; constructor(public type: string, public name: string) {} } class ParserContext { currentGroup?: ParserGroup; output: Record = {}; } class Parser { tomlLines: string[]; context: ParserContext; constructor(tomlString: string) { this.tomlLines = this._split(tomlString); this.context = new ParserContext(); } _sanitize(): void { const out: string[] = []; for (let i = 0; i < this.tomlLines.length; i++) { const s = this.tomlLines[i]; const trimmed = s.trim(); if (trimmed !== "") { out.push(s); } } this.tomlLines = out; this._removeComments(); this._mergeMultilines(); } _removeComments(): void { function isFullLineComment(line: string) { return line.match(/^#/) ? true : false; } function stringStart(line: string) { const m = line.match(/(?:=\s*\[?\s*)("""|'''|"|')/); if (!m) { return false; } // We want to know which syntax was used to open the string openStringSyntax = m[1]; return true; } function stringEnd(line: string) { // match the syntax used to open the string when searching for string close // e.g. if we open with ''' we must close with a ''' const reg = RegExp(`(? | unknown[] = {}, cObj: Record | unknown[] = {}, ): Record { const out: Record = {}; if (keys.length === 0) { return cObj as Record; } else { if (Object.keys(cObj).length === 0) { cObj = values; } const key: string | undefined = keys.pop(); if (key) { out[key] = cObj; } return this._unflat(keys, values, out); } } _groupToOutput(): void { assert(this.context.currentGroup != null, "currentGroup must be set"); const arrProperty = this.context.currentGroup.name .replace(/"/g, "") .replace(/'/g, "") .split("."); let u = {}; if (this.context.currentGroup.type === "array") { u = this._unflat(arrProperty, this.context.currentGroup.arrValues); } else { u = this._unflat(arrProperty, this.context.currentGroup.objValues); } deepAssign(this.context.output, u); delete this.context.currentGroup; } _split(str: string): string[] { const out = []; out.push(...str.split("\n")); return out; } _isGroup(line: string): boolean { const t = line.trim(); return t[0] === "[" && /\[(.*)\]/.exec(t) ? true : false; } _isDeclaration(line: string): boolean { return line.split("=").length > 1; } _createGroup(line: string): void { const captureReg = /\[(.*)\]/; if (this.context.currentGroup) { this._groupToOutput(); } let type; let m = line.match(captureReg); assert(m != null, "line mut be matched"); let name = m[1]; if (name.match(/\[.*\]/)) { type = "array"; m = name.match(captureReg); assert(m != null, "name must be matched"); name = m[1]; } else { type = "object"; } this.context.currentGroup = new ParserGroup(type, name); } _processDeclaration(line: string): KeyValuePair { const idx = line.indexOf("="); const key = line.substring(0, idx).trim(); const value = this._parseData(line.slice(idx + 1)); return new KeyValuePair(key, value); } _parseData(dataString: string): unknown { dataString = dataString.trim(); switch (dataString[0]) { case '"': case "'": return this._parseString(dataString); case "[": case "{": return this._parseInlineTableOrArray(dataString); default: { // Strip a comment. const match = /#.*$/.exec(dataString); if (match) { dataString = dataString.slice(0, match.index).trim(); } switch (dataString) { case "true": return true; case "false": return false; case "inf": case "+inf": return Infinity; case "-inf": return -Infinity; case "nan": case "+nan": case "-nan": return NaN; default: return this._parseNumberOrDate(dataString); } } } } _parseInlineTableOrArray(dataString: string): unknown { const invalidArr = /,\]/g.exec(dataString); if (invalidArr) { dataString = dataString.replace(/,]/g, "]"); } if ( (dataString[0] === "{" && dataString[dataString.length - 1] === "}") || (dataString[0] === "[" && dataString[dataString.length - 1] === "]") ) { const reg = /([a-zA-Z0-9-_\.]*) (=)/gi; let result; while ((result = reg.exec(dataString))) { const ogVal = result[0]; const newVal = ogVal .replace(result[1], `"${result[1]}"`) .replace(result[2], ":"); dataString = dataString.replace(ogVal, newVal); } return JSON.parse(dataString); } throw new TOMLError("Malformed inline table or array literal"); } _parseString(dataString: string): string { const quote = dataString[0]; // Handle First and last EOL for multiline strings if (dataString.startsWith(`"\\n`)) { dataString = dataString.replace(`"\\n`, `"`); } else if (dataString.startsWith(`'\\n`)) { dataString = dataString.replace(`'\\n`, `'`); } if (dataString.endsWith(`\\n"`)) { dataString = dataString.replace(`\\n"`, `"`); } else if (dataString.endsWith(`\\n'`)) { dataString = dataString.replace(`\\n'`, `'`); } let value = ""; for (let i = 1; i < dataString.length; i++) { switch (dataString[i]) { case "\\": i++; // See https://toml.io/en/v1.0.0-rc.3#string switch (dataString[i]) { case "b": value += "\b"; break; case "t": value += "\t"; break; case "n": value += "\n"; break; case "f": value += "\f"; break; case "r": value += "\r"; break; case "u": case "U": { // Unicode character const codePointLen = dataString[i] === "u" ? 4 : 6; const codePoint = parseInt( "0x" + dataString.slice(i + 1, i + 1 + codePointLen), 16, ); value += String.fromCodePoint(codePoint); i += codePointLen; break; } case "\\": value += "\\"; break; default: value += dataString[i]; break; } break; case quote: if (dataString[i - 1] !== "\\") { return value; } break; default: value += dataString[i]; break; } } throw new TOMLError("Incomplete string literal"); } _parseNumberOrDate(dataString: string): unknown { if (this._isDate(dataString)) { return new Date(dataString); } if (this._isLocalTime(dataString)) { return dataString; } // If binary / octal / hex const hex = /^(0(?:x|o|b)[0-9a-f_]*)/gi.exec(dataString); if (hex && hex[0]) { return hex[0].trim(); } const testNumber = this._isParsableNumber(dataString); if (testNumber !== false && !isNaN(testNumber as number)) { return testNumber; } return String(dataString); } _isLocalTime(str: string): boolean { const reg = /(\d{2}):(\d{2}):(\d{2})/; return reg.test(str); } _isParsableNumber(dataString: string): number | boolean { const m = /((?:\+|-|)[0-9_\.e+\-]*)[^#]/i.exec(dataString); if (!m) { return false; } else { return parseFloat(m[0].replace(/_/g, "")); } } _isDate(dateStr: string): boolean { const reg = /\d{4}-\d{2}-\d{2}/; return reg.test(dateStr); } _parseDeclarationName(declaration: string): string[] { const out = []; let acc = []; let inLiteral = false; for (let i = 0; i < declaration.length; i++) { const c = declaration[i]; switch (c) { case ".": if (!inLiteral) { out.push(acc.join("")); acc = []; } else { acc.push(c); } break; case `"`: if (inLiteral) { inLiteral = false; } else { inLiteral = true; } break; default: acc.push(c); break; } } if (acc.length !== 0) { out.push(acc.join("")); } return out; } _parseLines(): void { for (let i = 0; i < this.tomlLines.length; i++) { const line = this.tomlLines[i]; // TODO (zekth) Handle unflat of array of tables if (this._isGroup(line)) { // if the current group is an array we push the // parsed objects in it. if ( this.context.currentGroup && this.context.currentGroup.type === "array" ) { this.context.currentGroup.arrValues.push( this.context.currentGroup.objValues, ); this.context.currentGroup.objValues = {}; } // If we need to create a group or to change group if ( !this.context.currentGroup || (this.context.currentGroup && this.context.currentGroup.name !== line.replace(/\[/g, "").replace(/\]/g, "")) ) { this._createGroup(line); continue; } } if (this._isDeclaration(line)) { const kv = this._processDeclaration(line); const key = kv.key; const value = kv.value; if (!this.context.currentGroup) { this.context.output[key] = value; } else { this.context.currentGroup.objValues[key] = value; } } } if (this.context.currentGroup) { if (this.context.currentGroup.type === "array") { this.context.currentGroup.arrValues.push( this.context.currentGroup.objValues, ); } this._groupToOutput(); } } _cleanOutput(): void { this._propertyClean(this.context.output); } _propertyClean(obj: Record): void { const keys = Object.keys(obj); for (let i = 0; i < keys.length; i++) { let k = keys[i]; if (k) { let v = obj[k]; const pathDeclaration = this._parseDeclarationName(k); delete obj[k]; if (pathDeclaration.length > 1) { const shift = pathDeclaration.shift(); if (shift) { k = shift.replace(/"/g, ""); v = this._unflat(pathDeclaration, v as Record); } } else { k = k.replace(/"/g, ""); } obj[k] = v; if (v instanceof Object) { // deno-lint-ignore no-explicit-any this._propertyClean(v as any); } } } } parse(): Record { this._sanitize(); this._parseLines(); this._cleanOutput(); return this.context.output; } } // Bare keys may only contain ASCII letters, // ASCII digits, underscores, and dashes (A-Za-z0-9_-). function joinKeys(keys: string[]): string { // Dotted keys are a sequence of bare or quoted keys joined with a dot. // This allows for grouping similar properties together: return keys .map((str: string): string => { return str.match(/[^A-Za-z0-9_-]/) ? `"${str}"` : str; }) .join("."); } class Dumper { maxPad = 0; srcObject: Record; output: string[] = []; constructor(srcObjc: Record) { this.srcObject = srcObjc; } dump(): string[] { // deno-lint-ignore no-explicit-any this.output = this._parse(this.srcObject as any); this.output = this._format(); return this.output; } _parse(obj: Record, keys: string[] = []): string[] { const out = []; const props = Object.keys(obj); const propObj = props.filter((e: string): boolean => { if (obj[e] instanceof Array) { const d: unknown[] = obj[e] as unknown[]; return !this._isSimplySerializable(d[0]); } return !this._isSimplySerializable(obj[e]); }); const propPrim = props.filter((e: string): boolean => { if (obj[e] instanceof Array) { const d: unknown[] = obj[e] as unknown[]; return this._isSimplySerializable(d[0]); } return this._isSimplySerializable(obj[e]); }); const k = propPrim.concat(propObj); for (let i = 0; i < k.length; i++) { const prop = k[i]; const value = obj[prop]; if (value instanceof Date) { out.push(this._dateDeclaration([prop], value)); } else if (typeof value === "string" || value instanceof RegExp) { out.push(this._strDeclaration([prop], value.toString())); } else if (typeof value === "number") { out.push(this._numberDeclaration([prop], value)); } else if (typeof value === "boolean") { out.push(this._boolDeclaration([prop], value)); } else if ( value instanceof Array && this._isSimplySerializable(value[0]) ) { // only if primitives types in the array out.push(this._arrayDeclaration([prop], value)); } else if ( value instanceof Array && !this._isSimplySerializable(value[0]) ) { // array of objects for (let i = 0; i < value.length; i++) { out.push(""); out.push(this._headerGroup([...keys, prop])); out.push(...this._parse(value[i], [...keys, prop])); } } else if (typeof value === "object") { out.push(""); out.push(this._header([...keys, prop])); if (value) { const toParse = value as Record; out.push(...this._parse(toParse, [...keys, prop])); } // out.push(...this._parse(value, `${path}${prop}.`)); } } out.push(""); return out; } _isSimplySerializable(value: unknown): boolean { return ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof RegExp || value instanceof Date || value instanceof Array ); } _header(keys: string[]): string { return `[${joinKeys(keys)}]`; } _headerGroup(keys: string[]): string { return `[[${joinKeys(keys)}]]`; } _declaration(keys: string[]): string { const title = joinKeys(keys); if (title.length > this.maxPad) { this.maxPad = title.length; } return `${title} = `; } _arrayDeclaration(keys: string[], value: unknown[]): string { return `${this._declaration(keys)}${JSON.stringify(value)}`; } _strDeclaration(keys: string[], value: string): string { return `${this._declaration(keys)}"${value}"`; } _numberDeclaration(keys: string[], value: number): string { switch (value) { case Infinity: return `${this._declaration(keys)}inf`; case -Infinity: return `${this._declaration(keys)}-inf`; default: return `${this._declaration(keys)}${value}`; } } _boolDeclaration(keys: string[], value: boolean): string { return `${this._declaration(keys)}${value}`; } _dateDeclaration(keys: string[], value: Date): string { function dtPad(v: string, lPad = 2): string { return v.padStart(lPad, "0"); } const m = dtPad((value.getUTCMonth() + 1).toString()); const d = dtPad(value.getUTCDate().toString()); const h = dtPad(value.getUTCHours().toString()); const min = dtPad(value.getUTCMinutes().toString()); const s = dtPad(value.getUTCSeconds().toString()); const ms = dtPad(value.getUTCMilliseconds().toString(), 3); // formatted date const fData = `${value.getUTCFullYear()}-${m}-${d}T${h}:${min}:${s}.${ms}`; return `${this._declaration(keys)}${fData}`; } _format(): string[] { const rDeclaration = /(.*)\s=/; const out = []; for (let i = 0; i < this.output.length; i++) { const l = this.output[i]; // we keep empty entry for array of objects if (l[0] === "[" && l[1] !== "[") { // empty object if (this.output[i + 1] === "") { i += 1; continue; } out.push(l); } else { const m = rDeclaration.exec(l); if (m) { out.push(l.replace(m[1], m[1].padEnd(this.maxPad))); } else { out.push(l); } } } // Cleaning multiple spaces const cleanedOutput = []; for (let i = 0; i < out.length; i++) { const l = out[i]; if (!(l === "" && out[i + 1] === "")) { cleanedOutput.push(l); } } return cleanedOutput; } } /** * Stringify dumps source object into TOML string and returns it. * @param srcObj */ export function stringify(srcObj: Record): string { return new Dumper(srcObj).dump().join("\n"); } /** * Parse parses TOML string into an object. * @param tomlString */ export function parse(tomlString: string): Record { // File is potentially using EOL CRLF tomlString = tomlString.replace(/\r\n/g, "\n").replace(/\\\n/g, "\n"); return new Parser(tomlString).parse(); }