// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. // 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.length === 0 || str.match(/[^A-Za-z0-9_-]/) ? JSON.stringify(str) : str; }) .join("."); } type ArrayType = | "ONLY_PRIMITIVE" | "ONLY_OBJECT_EXCLUDING_ARRAY" | "MIXED"; /** * Options for {@linkcode stringify}. */ export interface StringifyOptions { /** * Define if the keys should be aligned or not. * * @default {false} */ keyAlignment?: boolean; } class Dumper { maxPad = 0; srcObject: Record; output: string[] = []; #arrayTypeCache = new Map(); constructor(srcObjc: Record) { this.srcObject = srcObjc; } dump(fmtOptions: StringifyOptions = {}): string[] { // deno-lint-ignore no-explicit-any this.output = this.#printObject(this.srcObject as any); this.output = this.#format(fmtOptions); return this.output; } #printObject(obj: Record, keys: string[] = []): string[] { const out = []; const props = Object.keys(obj); const inlineProps = []; const multilineProps = []; for (const prop of props) { if (this.#isSimplySerializable(obj[prop])) { inlineProps.push(prop); } else { multilineProps.push(prop); } } const sortedProps = inlineProps.concat(multilineProps); for (const prop of sortedProps) { 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 ) { const arrayType = this.#getTypeOfArray(value); if (arrayType === "ONLY_PRIMITIVE") { out.push(this.#arrayDeclaration([prop], value)); } else if (arrayType === "ONLY_OBJECT_EXCLUDING_ARRAY") { // array of objects for (let i = 0; i < value.length; i++) { out.push(""); out.push(this.#headerGroup([...keys, prop])); out.push(...this.#printObject(value[i], [...keys, prop])); } } else { // this is a complex array, use the inline format. const str = value.map((x) => this.#printAsInlineValue(x)).join(","); out.push(`${this.#declaration([prop])}[${str}]`); } } else if (typeof value === "object") { out.push(""); out.push(this.#header([...keys, prop])); if (value) { const toParse = value as Record; out.push(...this.#printObject(toParse, [...keys, prop])); } // out.push(...this._parse(value, `${path}${prop}.`)); } } out.push(""); return out; } #isPrimitive(value: unknown): boolean { return value instanceof Date || value instanceof RegExp || ["string", "number", "boolean"].includes(typeof value); } #getTypeOfArray(arr: unknown[]): ArrayType { if (this.#arrayTypeCache.has(arr)) { return this.#arrayTypeCache.get(arr)!; } const type = this.#doGetTypeOfArray(arr); this.#arrayTypeCache.set(arr, type); return type; } #doGetTypeOfArray(arr: unknown[]): ArrayType { if (!arr.length) { // any type should be fine return "ONLY_PRIMITIVE"; } const onlyPrimitive = this.#isPrimitive(arr[0]); if (arr[0] instanceof Array) { return "MIXED"; } for (let i = 1; i < arr.length; i++) { if ( onlyPrimitive !== this.#isPrimitive(arr[i]) || arr[i] instanceof Array ) { return "MIXED"; } } return onlyPrimitive ? "ONLY_PRIMITIVE" : "ONLY_OBJECT_EXCLUDING_ARRAY"; } #printAsInlineValue(value: unknown): string | number { if (value instanceof Date) { return `"${this.#printDate(value)}"`; } else if (typeof value === "string" || value instanceof RegExp) { return JSON.stringify(value.toString()); } else if (typeof value === "number") { return value; } else if (typeof value === "boolean") { return value.toString(); } else if ( value instanceof Array ) { const str = value.map((x) => this.#printAsInlineValue(x)).join(","); return `[${str}]`; } else if (typeof value === "object") { if (!value) { throw new Error("should never reach"); } const str = Object.keys(value).map((key) => { return `${joinKeys([key])} = ${ // deno-lint-ignore no-explicit-any this.#printAsInlineValue((value as any)[key])}`; }).join(","); return `{${str}}`; } throw new Error("should never reach"); } #isSimplySerializable(value: unknown): boolean { return ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof RegExp || value instanceof Date || (value instanceof Array && this.#getTypeOfArray(value) !== "ONLY_OBJECT_EXCLUDING_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)}${JSON.stringify(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}`; } #printDate(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 fData; } #dateDeclaration(keys: string[], value: Date): string { return `${this.#declaration(keys)}${this.#printDate(value)}`; } #format(options: StringifyOptions = {}): string[] { const { keyAlignment = false } = options; const rDeclaration = /^(\".*\"|[^=]*)\s=/; const out = []; for (let i = 0; i < this.output.length; i++) { const l = this.output[i] as string; // we keep empty entry for array of objects if (l[0] === "[" && l[1] !== "[") { // non-empty object with only subobjects as properties if ( this.output[i + 1] === "" && this.output[i + 2]?.slice(0, l.length) === l.slice(0, -1) + "." ) { i += 1; continue; } out.push(l); } else { if (keyAlignment) { const m = rDeclaration.exec(l); if (m && m[1]) { out.push(l.replace(m[1], m[1].padEnd(this.maxPad))); } else { out.push(l); } } else { out.push(l); } } } // Cleaning multiple spaces const cleanedOutput = []; for (let i = 0; i < out.length; i++) { const l = out[i] as string; if (!(l === "" && out[i + 1] === "")) { cleanedOutput.push(l); } } return cleanedOutput; } } /** * Converts an object to a {@link https://toml.io | TOML} string. * * @example Usage * ```ts * import { stringify } from "@std/toml/stringify"; * import { assertEquals } from "@std/assert"; * * const obj = { * title: "TOML Example", * owner: { * name: "Bob", * bio: "Bob is a cool guy", * } * }; * const tomlString = stringify(obj); * assertEquals(tomlString, `title = "TOML Example"\n\n[owner]\nname = "Bob"\nbio = "Bob is a cool guy"\n`); * ``` * @param obj Source object * @param options Options for stringifying. * @returns TOML string */ export function stringify( obj: Record, options?: StringifyOptions, ): string { return new Dumper(obj).dump(options).join("\n"); }