/** * Debugging-ish helpers for `FabricValue`s. */ import { isPlainObject } from "@commonfabric/utils/types"; import { FabricInstance, FabricPrimitive, FabricSpecialObject, } from "./interface.ts"; import { codecOf } from "@/codec-common/index.ts"; /** * Sentinel marker used to wrap content that should appear unquoted in the * final output. The replacer brackets a bare-token payload (e.g. `42n` or * `undefined`) with this marker; a post-processing pass then strips both the * markers and the surrounding JSON-string quotes. */ const UNQUOTE_MARKER = "@@DEBUG_UNQUOTE@@"; /** Regex matching a marked, JSON-quoted payload. Group 1 is the payload. */ const UNQUOTE_RE = /"@@DEBUG_UNQUOTE@@(.*?)@@DEBUG_UNQUOTE@@"/g; /** Wraps a payload in the sentinel markers for unquoting. */ function marked(payload: string): string { return `${UNQUOTE_MARKER}${payload}${UNQUOTE_MARKER}`; } /** * Strips sentinel markers (and surrounding JSON quotes) in a stringify output. * The captured payload body is decoded back through `JSON.parse` so that any * quote / backslash escapes introduced by the outer `JSON.stringify` round- * trip are undone (e.g. so that the symbol-form payload `Symbol.for("name")` * retains its literal `"`s rather than coming out as `Symbol.for(\"name\")`). */ function unquoteMarked(json: string): string { return json.replace(UNQUOTE_RE, (_match, body) => { return JSON.parse(`"${body}"`); }); } /** * Helper class for rendering debug-string representations of values. */ class DebugStringifier { #circles = new Set(); #unusedCircles = new Set(); #indent: number | undefined; #value: unknown; constructor(value: unknown, indent?: number) { this.#value = value; this.#indent = indent; } /** * Renders the debug-string form of the configured value, with the configured * indentation if any. */ render() { this.#findCircles(this.#value); const rawResult = JSON.stringify( this.#value, (_key: string, value: unknown) => this.#replacer(value), this.#indent, ); return unquoteMarked(rawResult); } #findCircles( value: unknown, possibleCircles: Set = new Set(), ) { if (!value || (typeof value !== "object") || this.#circles.has(value)) { return; } else if (possibleCircles.has(value)) { this.#circles.add(value); this.#unusedCircles.add(value); return; } const valueObj = value as Record; possibleCircles.add(value); for (const key in valueObj) { this.#findCircles(valueObj[key], possibleCircles); } possibleCircles.delete(value); } #replacer(value: unknown) { switch (typeof value) { case "bigint": { return marked(`${value}n`); } case "function": { return marked( value.name === "" ? "(...) => {...}" : `function ${value.name}(...) {...}`, ); } case "number": { if (Number.isFinite(value)) { return Object.is(value, -0) ? marked("-0") : value; } else if (Number.isNaN(value)) { return marked("NaN"); } else if (value === Infinity) { return marked("Infinity"); } else if (value === -Infinity) { return marked("-Infinity"); } else { // Shouldn't happen; there aren't any other non-finite possibilites. return marked(``); } } case "object": { if (value === null) { // Let `JSON.stringify()` just render it directly as `"null"`. return null; } else if (this.#circles.has(value)) { if (this.#unusedCircles.has(value)) { this.#unusedCircles.delete(value); return value; } return marked(""); } else if (isPlainObject(value) || Array.isArray(value)) { return value; } // Non-plain object. const className = (value as { constructor?: { name?: string } }).constructor?.name ?? ""; if (value instanceof FabricSpecialObject) { // The slash here is to suggest that what we're rendering is a known // encodable type, and not just an instance of some random class. // TODO(danfuzz): This should get fancier/smarter, e.g. by rendering // some of the instance's actual state instead of an opaque `(...)`. let fullTag; try { fullTag = codecOf(value).tagForValue(value); } catch { // Never let the debug formatter throw; fall back to the class name. fullTag = className; } const tag = fullTag.replace(/@.*$/, ""); return marked(`/${tag}(...)`); } else { // Non-plain non-fabric object. Punt on attempting to render the // innards. return marked(`new ${className}(...)`); } } case "symbol": { const key = Symbol.keyFor(value); if (key === undefined) { // Uninterned ("unique") symbol. const description = value.description; return marked( (description === undefined) ? "Symbol()" : `Symbol(${JSON.stringify(description)})`, ); } else { // Interned symbol. return marked(`Symbol.for(${JSON.stringify(key)})`); } } case "undefined": { return marked("undefined"); } default: { return value; } } } } /** * Renders the debug-string form of the given value with optional indentation. */ function renderDebugString(value: unknown, indent?: number) { try { return new DebugStringifier(value, indent).render(); } catch { return ""; } } /** * Produces a compact string representation of a value, optionally truncating to * a specified maximum length. When truncating is requested and turns out to be * necessary, the returned result will be the indicated length, which includes * an "ASCII ellipsis" of `...`. * * This function handles: * * all normal JSON-compatible values. * * other JavaScript primitive values: * * bigints. * * symbols, both interned and uninterned. * * non-finite numbers. * * `-0` rendered as such. * * functions, rendered in a simplified form and without any additional * properties listed. * * objects which define `toJSON()`, calling that method in the usual way. * * objects and arrays with circular references, rendering the back-references * as ``. * * If the stringification could not be completed (stack overflow, object * `toJSON()` conversion error, etc.), this function returns the literal string * `""`. * * **Note:** In _many_ cases, the output of this function is valid JSON text, * but not _all_ cases. This function must _not_ be relied on to produce a * parseable string. */ export function toCompactDebugString( value: unknown, maxLength?: number, ): string { const result = renderDebugString(value); if (typeof maxLength === "number") { const actualMax = Math.max(Math.floor(maxLength), 3); if (result.length > actualMax) { return result.slice(0, actualMax - 3) + "..."; } } return result; } /** * Produces an indented string representation of a value. * * This function handles: * * all normal JSON-compatible values. * * other JavaScript primitive values: * * bigints. * * symbols, both interned and uninterned. * * non-finite numbers. * * `-0` rendered as such. * * functions, rendered in a simplified form and without any additional * properties listed. * * objects which define `toJSON()`, calling that method in the usual way. * * objects and arrays with circular references, rendering the back-references * as ``. * * If the stringification could not be completed (stack overflow, object * `toJSON()` conversion error, etc.), this function returns the literal string * `""`. * * **Note:** In _many_ cases, the output of this function is valid JSON text, * but not _all_ cases. This function must _not_ be relied on to produce a * parseable string. */ export function toIndentedDebugString(value: unknown): string { return renderDebugString(value, 2); } /** * Produces a short human-readable kind-string for a value, suitable for * error messages and other diagnostic contexts where the caller wants to * say something like _"can't operate on a `${toDebugKindString(value)}`"_. * * Distinguishes: * * - `null` / `undefined` -- rendered literally. * - Plain objects and arrays -- `"object"` / `"array"`. * - `FabricInstance` and `FabricPrimitive` -- rendered with their concrete * subclass constructor name (e.g. `"FabricInstance (FabricError)"`). * - Other class instances -- rendered with their constructor name. * - JS primitives -- rendered as their `typeof` (`"number"`, `"string"`, * `"bigint"`, `"boolean"`, `"symbol"`, `"function"`). */ export function toDebugKindString(value: unknown): string { if (value === null) return "null"; if (value === undefined) return "undefined"; if (Array.isArray(value)) return "array"; if (typeof value !== "object") return typeof value; if (value instanceof FabricInstance) { return `FabricInstance (${value.constructor.name})`; } if (value instanceof FabricPrimitive) { return `FabricPrimitive (${value.constructor.name})`; } if (isPlainObject(value)) return "object"; return value.constructor?.name ?? "object"; }