std/encoding/toml.ts

269 lines
8.5 KiB
TypeScript

// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
export { parse } from "./_toml/parser.ts";
// 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_-]/) ? JSON.stringify(str) : str;
})
.join(".");
}
enum ArrayType {
ONLY_PRIMITIVE,
ONLY_OBJECT_EXCLUDING_ARRAY,
MIXED,
}
export interface FormatOptions {
keyAlignment?: boolean;
}
class Dumper {
maxPad = 0;
srcObject: Record<string, unknown>;
output: string[] = [];
#arrayTypeCache = new Map<unknown[], ArrayType>();
constructor(srcObjc: Record<string, unknown>) {
this.srcObject = srcObjc;
}
dump(fmtOptions: FormatOptions = {}): 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<string, unknown>, 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 (let i = 0; i < sortedProps.length; i++) {
const prop = sortedProps[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
) {
const arrayType = this.#getTypeOfArray(value);
if (arrayType === ArrayType.ONLY_PRIMITIVE) {
out.push(this.#arrayDeclaration([prop], value));
} else if (arrayType === 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<string, unknown>;
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 ArrayType.ONLY_PRIMITIVE;
}
const onlyPrimitive = this.#isPrimitive(arr[0]);
if (arr[0] instanceof Array) {
return ArrayType.MIXED;
}
for (let i = 1; i < arr.length; i++) {
if (
onlyPrimitive !== this.#isPrimitive(arr[i]) || arr[i] instanceof Array
) {
return ArrayType.MIXED;
}
}
return onlyPrimitive
? ArrayType.ONLY_PRIMITIVE
: ArrayType.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) => {
// deno-lint-ignore no-explicit-any
return `${key} = ${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) !== ArrayType.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: FormatOptions = {}): string[] {
const { keyAlignment = false } = options;
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 {
if (keyAlignment) {
const m = rDeclaration.exec(l);
if (m) {
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];
if (!(l === "" && out[i + 1] === "")) {
cleanedOutput.push(l);
}
}
return cleanedOutput;
}
}
/**
* Stringify dumps source object into TOML string and returns it.
* @param srcObj
* @param [fmtOptions] format options
* @param [fmtOptions.keyAlignment] whether to algin key
*/
export function stringify(
srcObj: Record<string, unknown>,
fmtOptions?: FormatOptions,
): string {
return new Dumper(srcObj).dump(fmtOptions).join("\n");
}