/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { serialize } from "#util/serialize.js"; import { ImplementationError, InternalError, MatterError } from "../MatterError.js"; import { Bytes } from "../util/Bytes.js"; import { Lifecycle } from "../util/Lifecycle.js"; import { isObject } from "../util/Type.js"; import { Diagnostic } from "./Diagnostic.js"; import { LogLevel } from "./LogLevel.js"; const INDENT_SPACES = 2; /** * Get a diagnostic formatter for the specified format. * * A "diagnostic formatter" converts arbitrary values into a formatted string. Formatting is controlled by type and the * {@link Diagnostic.presentation} and {@link Diagnostic.value} properties. */ export function LogFormat(format: string | LogFormat.Formatter): LogFormat.Formatter { if (typeof format === "function") { return format; } const fn = LogFormat.formats[format]; if (fn === undefined) { throw new ImplementationError(`Unsupported log format "${format}"`); } return fn; } /** * Log stylization support. */ export namespace LogFormat { /** * A function that formats a log message. */ export type Formatter = (diagnostic: unknown, indents?: number) => string; /** * Supported formats. You may add to this object to register new named formats. */ export const formats: Record = {}; /** * Built in formatter that produces plaintext. */ export const PLAIN = "plain"; /** * Built in formatter that produces text styled using ANSI escapes. **/ export const ANSI = "ansi"; /** * Built in formatter that produces HTML. */ export const HTML = "html"; } export type DiagnosticProducer = () => string; interface Formatter { message(message: Diagnostic.Message): string; text(text: string): string; indent(producer: DiagnosticProducer): string; break(): string; key(text: string): string; keylike(text: string): string; value(producer: DiagnosticProducer): string; strong(producer: DiagnosticProducer): string; weak(producer: DiagnosticProducer): string; error(producer: DiagnosticProducer): string; status(status: Lifecycle.Status, producer: DiagnosticProducer): string; via(text: string): string; added(producer: DiagnosticProducer): string; deleted(producer: DiagnosticProducer): string; } const LifecycleIcons = { [Lifecycle.Status.Unknown]: "?", [Lifecycle.Status.Inactive]: "💤", [Lifecycle.Status.Initializing]: "⌛", [Lifecycle.Status.Active]: "✓", [Lifecycle.Status.Crashed]: "✗", [Lifecycle.Status.Destroying]: "☠︎", [Lifecycle.Status.Destroyed]: "☠︎", }; /** * Create a small utility shared by plain and ansi formats. */ function plaintextCreator(indents: number) { let broke = false; return { text(value: string) { if (broke) { broke = false; return `\n${"".padStart(indents * INDENT_SPACES)}${value}`; } return value; }, break() { broke = true; return ""; }, indent(producer: () => string) { indents++; const result = producer(); indents--; return result; }, }; } function statusIcon(status: Lifecycle.Status) { return LifecycleIcons[status] ?? LifecycleIcons[Lifecycle.Status.Unknown]; } LogFormat.formats.plain = function plain(diagnostic: unknown, indents = 0) { const creator = plaintextCreator(indents); const formatter = { ...creator, message: message => { const formattedValues = ensureIndented(renderDiagnostic(message.values, formatter)); return `${formatTime(message.now)} ${LogLevel[ message.level ].toUpperCase()} ${message.facility} ${message.prefix}${formattedValues}`; }, key: text => creator.text(`${text}: `), keylike: text => creator.text(`${text}`), value: producer => creator.text(producer()), strong: producer => creator.text(producer()), weak: producer => creator.text(producer()), error: producer => creator.text(producer()), status: (status, producer) => `${creator.text(statusIcon(status))}${producer()}`, via: text => creator.text(text), added: producer => creator.text(`+${producer()}`), deleted: producer => creator.text(`-${producer()}`), } satisfies Formatter; return renderDiagnostic(diagnostic, formatter); }; const ANSI_CODES = { reset: 0, bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, default: 39, gray: 90, }; type AnsiCode = "normal" | keyof typeof ANSI_CODES; function ansiEscape(...codes: AnsiCode[]) { const numbers = []; for (const code of codes) { if (code === "normal") { continue; } const number = ANSI_CODES[code]; if (number === undefined) { throw new InternalError(`Invalid ANSI code ${code}`); } numbers.push(number); } if (!numbers.length) { return ""; } return `\u001b[${numbers.join(";")}m`; } interface Style { color?: "default" | keyof typeof ANSI_CODES; dim?: boolean; bold?: boolean; } const Styles = { default: { color: "default" }, prefix: { color: "default", dim: true }, facility: { color: "gray", bold: true }, debug: { color: "gray" }, info: { color: "default" }, notice: { color: "green" }, warn: { color: "yellow" }, error: { color: "red" }, fatal: { color: "red", bold: true }, key: { color: "blue" }, value: { color: "default", dim: true }, strong: { bold: true }, weak: { dim: true }, ballotCheck: { color: "green" }, ballotCross: { color: "red" }, unknown: { color: "gray" }, inactive: { color: "gray" }, initializing: { color: "yellow" }, active: { color: "green" }, crashed: { color: "red" }, destroying: { color: "gray" }, destroyed: { color: "gray" }, via: { color: "magenta" }, added: { color: "green" }, deleted: { color: "red" }, } as const satisfies Record; type StyleName = keyof typeof Styles; LogFormat.formats.ansi = function ansi(diagnostic: unknown, indents = 0) { let baseStyleChanged = false; const creator = plaintextCreator(indents); const currentStyle: Style = { color: "default", dim: false, bold: false, }; const styles: StyleName[] = ["default"]; function normal(text: string) { return style(styles[styles.length - 1] ?? "default", text); } const formatter = { message: ({ now, level, facility, prefix: nestPrefix, values }) => { baseStyleChanged = true; styles[0] = (LogLevel[level] ?? "default").toLowerCase() as StyleName; const prefix = style("prefix", `${formatTime(now)} ${LogLevel[level].toUpperCase().padEnd(6)}`); facility = style( "facility", facility.length > 20 ? `${facility.slice(0, 10)}~${facility.slice(facility.length - 9)}` : facility.padEnd(20), ); if (nestPrefix) { nestPrefix = style("prefix", nestPrefix); } const formattedValues = ensureIndented(renderDiagnostic(values, formatter)); return `${prefix} ${facility} ${nestPrefix}${formattedValues}`; }, text: text => creator.text(normal(text)), indent: producer => creator.indent(producer), break: () => { // After the first line revert to default styling so e.g. stack traces aren't all red if (baseStyleChanged) { baseStyleChanged = false; styles[0] = "default"; } return creator.break(); }, key: text => creator.text(style("key", `${text}: `)), keylike: text => creator.text(style("key", `${text}`)), value: producer => { styles.push("value"); const result = producer(); styles.pop(); return result; }, strong: producer => { styles.push("strong"); const result = producer(); styles.pop(); return result; }, weak: producer => { styles.push("weak"); const result = producer(); styles.pop(); return result; }, error: producer => { styles.push("error"); const result = producer(); styles.pop(); return result; }, status: (status, producer) => { styles.push(status); const result = `${creator.text(style(status, statusIcon(status)))}${producer()}`; styles.pop(); return result; }, via: text => creator.text(style("via", text)), added: producer => { styles.push("added"); const result = `${creator.text(style("added", "+"))}${producer()}`; styles.pop(); return result; }, deleted: producer => { styles.push("deleted"); const result = `${creator.text(style("deleted", "-"))}${producer()}`; styles.pop(); return result; }, } satisfies Formatter; return renderDiagnostic(diagnostic, formatter) + ansiEscape("reset"); // Convert a style name into a set of escape codes to transition state function escapes(styleName: StyleName) { const style = Styles[styleName] as Style; if (style === undefined) { throw new InternalError(`Invalid ANSI style "${styleName}"`); } const targetDim = !!style.dim; const targetBold = !!style.bold; let targetColor = style.color; // Compute target color from style stack if not explicit if (!targetColor) { for (let i = styles.length; i > 0; i--) { const color = (Styles[styles[i - 1] ?? "default"] as Style).color; if (color) { targetColor = color; break; } } } const codes = Array(); if ((!targetDim && currentStyle.dim) || (!targetBold && currentStyle.bold)) { // Don't think we can reset dim/bold without full reset codes.push("reset"); currentStyle.dim = false; currentStyle.bold = false; currentStyle.color = "default"; } if (targetDim !== currentStyle.dim) { codes.push("dim"); currentStyle.dim = true; } if (targetBold !== currentStyle.bold) { codes.push("bold"); currentStyle.bold = true; } if (targetColor && targetColor !== currentStyle.color) { codes.push(targetColor); currentStyle.color = targetColor; } return ansiEscape(...codes); } // Apply style codes. Maintains color state (via escapes) so values must be rendered sequentially as they appear function style(style: StyleName, text: string) { if (text === "") { return text; } const segments = text.match(/[^✓✔✗✘]+|[✓✔✗✘]/g); if (segments === null) { throw new InternalError("ANSI text processing regex failure"); } return segments .map(segment => { let esc; switch (segment) { case "✓": case "✔": esc = escapes("ballotCheck"); break; case "✗": case "✘": esc = escapes("ballotCross"); break; default: esc = escapes(style); break; } return `${esc}${segment}`; }) .join(""); } }; function htmlSpan(type: string, inner: string) { return `${inner}`; } LogFormat.formats.html = function html(diagnostic: unknown) { function escape(text: string) { return text.toString().replace(//g, ">"); } const formatter = { message: ({ now, level, facility, prefix, values }) => { prefix = prefix.replace(/ /g, " "); const formattedValues = renderDiagnostic(values, formatter); return htmlSpan( `line ${LogLevel[level].toLowerCase()}`, `${htmlSpan("time", formatTime(now))} ${htmlSpan("level", LogLevel[level].toUpperCase())} ${htmlSpan( "facility", facility, )} ${prefix}${formattedValues}`, ); }, text: escape, break: () => "
", indent: producer => htmlSpan("indent", producer()), key: text => htmlSpan("key", `${escape(text)}:`) + " ", keylike: text => htmlSpan("key", `${escape(text)}`), value: producer => htmlSpan("value", producer()), strong: producer => `${producer()}`, weak: producer => htmlSpan("weak", producer()), error: producer => htmlSpan("error", producer()), status: (status, producer) => htmlSpan(`status-${status}`, producer()), via: text => htmlSpan("via", escape(text)), added: producer => htmlSpan("added", producer()), deleted: producer => htmlSpan("deleted", producer()), } satisfies Formatter; return renderDiagnostic(diagnostic, formatter); }; /** * Render a value based on its JS type. */ function renderValue(value: unknown, formatter: Formatter, squash: boolean): string { if (value === undefined) { return formatter.text("undefined"); } if (value === null) { return formatter.text("null"); } if (Bytes.isBytes(value)) { return formatter.text(Bytes.toHex(value)); } if (value instanceof Error) { return renderDiagnostic(Diagnostic.error(value), formatter); } if (typeof value === "object" && Symbol.iterator in value && !(value instanceof String)) { const list = sequenceToList(value as Iterable); if (!list.length) { return ""; } if (list.length > 1) { return renderList(list, formatter); } const first = valueFor(list[0]) as unknown[]; return first .map(e => { if (typeof e === "string" && !squash) { e = e.trim(); } return renderDiagnostic(e, formatter); }) .filter(e => e.length > 0) .join(squash ? "" : " "); } if (value instanceof Date) { return formatter.text(formatTime(value)); } if (typeof value === "object") { if (value instanceof String) { return value.toString(); } return formatter.text(serialize(value) ?? "undefined"); } if (typeof value === "function") { return renderDiagnostic(value(), formatter); } const text = typeof value === "string" || value instanceof String ? value : value.toString().trim(); if (!text.includes("\n")) { return formatter.text(text as string); } return renderList(text.split("\n"), formatter); } function renderList(value: Iterable, formatter: Formatter) { const parts = Array(); for (const v of value) { parts.push(renderDiagnostic(v, formatter)); formatter.break(); } return parts.join(""); } function renderIndentedList(value: Iterable, formatter: Formatter) { return formatter.indent(() => { return renderList(value, formatter); }); } function renderDictionary(value: object, formatter: Formatter) { const entries = value instanceof Map ? value.entries() : Object.entries(value); const parts = []; for (const [k, v] of entries) { if (parts.length) { parts.push(" "); } const suppressKey = isObject(v) && Diagnostic.presentationOf(v) === "flag"; if (!suppressKey) { parts.push(formatter.key(k)); } const formattedValue = formatter.value(() => renderDiagnostic(v, formatter)); if (!suppressKey || formattedValue.length) { parts.push(formattedValue); } else { // if flag but the value is empty we need to remove the last space if added above if (parts.length && parts[parts.length - 1] === " ") { parts.pop(); } } } return parts.join("").trim(); } function valueFor(value: unknown) { if (typeof value !== "object" || value === null) { return value; } if (Diagnostic.value in value) { const proxied = Diagnostic.valueOf(value); if (proxied === value) { throw new InternalError("Diagnostic value proxies to itself"); } return proxied; } return value; } function presentationFor(value: unknown) { if (typeof value !== "object" || value === null) { return; } if (Diagnostic.presentation in value) { return Diagnostic.presentationOf(value); } const proxied = Diagnostic.valueOf(value); if (proxied && proxied !== value) { if (proxied === value) { throw new InternalError("Diagnostic value proxies to itself"); } return presentationFor(proxied); } } /** * Render a value with presentation support */ function renderDiagnostic(value: unknown, formatter: Formatter, ignorePresentation?: boolean): string { const presentation = ignorePresentation ? undefined : presentationFor(value); const logValue = valueFor(value); if (logValue === value) { // Ignore presentation when we recurse or it would be an infinite loop ignorePresentation = true; } else { ignorePresentation = undefined; value = logValue; } switch (presentation) { case undefined: return renderValue(value, formatter, false); case "message": if (value === undefined || value === null) { throw new ImplementationError("Diagnostic message is not an object"); } return formatter.message(Diagnostic.message(value)); case "list": if (typeof (value as Iterable)?.[Symbol.iterator] !== "function") { throw new ImplementationError("Diagnostic list is not iterable"); } return renderIndentedList(value as Iterable, formatter); case "squash": return renderValue(value, formatter, true); case "strong": return formatter.strong(() => renderDiagnostic(value, formatter, ignorePresentation)); case "weak": return formatter.weak(() => renderDiagnostic(value, formatter, ignorePresentation)); case "added": return formatter.added(() => renderDiagnostic(value, formatter, ignorePresentation)); case "deleted": return formatter.deleted(() => renderDiagnostic(value, formatter, ignorePresentation)); case "flag": return (value as string).length ? formatter.keylike(value as string) : ""; case "error": return formatter.error(() => renderDiagnostic(value, formatter, ignorePresentation)); case "via": return formatter.via(`${value}`); case "dictionary": if (typeof value !== "object") { throw new ImplementationError("Diagnostic dictionary is not an object"); } return renderDictionary(value as object, formatter); case Lifecycle.Status.Unknown: case Lifecycle.Status.Inactive: case Lifecycle.Status.Initializing: case Lifecycle.Status.Active: case Lifecycle.Status.Crashed: case Lifecycle.Status.Destroyed: return formatter.status(presentation, () => renderDiagnostic(value, formatter, ignorePresentation)); default: throw new ImplementationError(`Unsupported diagnostic presentation "${presentation}"`); } } /** * Group items in an iterable based on their presentation. The result is then * appropriate for rendering as a list. */ function sequenceToList(sequence: Iterable) { let group: unknown[] | undefined; const list = Array(); for (const value of sequence) { if (presentationFor(value) === "list") { group = undefined; list.push(value as unknown[]); continue; } if (!group) { list.push((group = [value])); } else { group.push(value); } } return list; } function formatTime(time: Date) { return `${time.getFullYear()}-${(time.getMonth() + 1).toString().padStart(2, "0")}-${time .getDate() .toString() .padStart(2, "0")} ${time.getHours().toString().padStart(2, "0")}:${time .getMinutes() .toString() .padStart(2, "0")}:${time.getSeconds().toString().padStart(2, "0")}.${time .getMilliseconds() .toString() .padStart(3, "0")}`; } /** * Multiline messages should always have whitespace as the first character after newlines. Ensure this is so. */ function ensureIndented(text: string) { if (text.match(/\n\S/)) { return text.replace(/\n/g, "\n "); } return text; } if (MatterError.formatterFor === MatterError.defaultFormatterFactory) { MatterError.formatterFor = LogFormat; } if (typeof MatterHooks !== "undefined") { MatterHooks.messageAndStackFor = (error: unknown, parentStack?: string[]) => { const { message, stack, stackLines } = Diagnostic.messageAndStackFor(error, parentStack); let stackStr; if (stack) { stackStr = stack.map(frame => LogFormat.formats.ansi(frame).trim()).join("\n"); } return { message, stack: stackStr, stackLines }; }; }