mirror of
https://github.com/denoland/std.git
synced 2024-11-21 20:50:22 +00:00
d102a10235
* refactor: import from `@std/assert` * update
293 lines
9.1 KiB
TypeScript
293 lines
9.1 KiB
TypeScript
// 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<string, unknown>;
|
|
output: string[] = [];
|
|
#arrayTypeCache = new Map<unknown[], ArrayType>();
|
|
constructor(srcObjc: Record<string, unknown>) {
|
|
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<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 (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<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 "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<string, unknown>,
|
|
options?: StringifyOptions,
|
|
): string {
|
|
return new Dumper(obj).dump(options).join("\n");
|
|
}
|