1/**
2 * the default stack trace formatters in node.js, deno, and bun are all
3 * extremely low quality. they don't highlight the most important information
4 * and spend too much space on showing source code. this module provides tools
5 * to {@linkcode parse} and {@linkcode format} errors. see {@linkcode format}'s
6 * comment for design decisions.
7 *
8 * to get a taste of the formatter:
9 * ```ts
10 * try {
11 * processFile(undefined);
12 * } catch (err) {
13 * console.error(stack.parse(err));
14 * console.error(stack.format(err, true));
15 * }
16 *
17 * function processFile(file) {
18 * return fs.createReadStream(file);
19 * }
20 *
21 * import * as fs from "node:fs";
22 * import * as stack from "@clo/lib/log/stack";
23 * ```
24 *
25 * @module
26 */
27
28/** retrieved from `parse` */
29export interface Frame {
30 /** `null` if top-level code or unnamed function. */
31 fn: string | null;
32 /** `null` if from a built in without a module name. */
33 file: string | null;
34 /** `null` if the source provider doesn't have a source location. */
35 line: number | null;
36 /** almost certainly defined if `line` is, but the runtime may omit it */
37 col: number | null;
38}
39
40/**
41 * parse a error stack trace from either v8 (node, deno, chrome),
42 * jsc (safari, bun), or spidermonkey (firefox).
43 *
44 * this function is derived from clover's work on bun's DevServer, which she
45 * derived from stacktracejs. this file is more refined than those two:
46 * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts
47 * https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js
48 *
49 * supports parsing v8, JavaScriptCore, SpiderMonkey, and IE error stack
50 * frames, effectively working in every JavaScript environment. leaves
51 * filesystem urls and paths intact (won't convert 'file:///' to or from a file
52 * path)
53 */
54export function parse(error: Error | string): Frame[] | null {
55 const stack = (error as Error)?.stack ?? error;
56 if (typeof stack !== "string") return null;
57 // a v8 trace begins with the message, which is all there is without frames
58 if (typeof error !== "string" && stack === String(error)) return [];
59 return stack.match(regexV8Stack)
60 ? parseV8OrIe(stack)
61 : parseJscOrSpidermonkey(stack);
62}
63
64/**
65 * capture and store for later, or use to trace the caller. with the
66 * default `trimStart` of `2`, the caller file is `capture()[0]?.file`.
67 */
68export function capture(trimStart = 2): Frame[] {
69 return parse(new Error())?.slice(trimStart) ?? [];
70}
71
72/** an error with a method by this key prints itself, given whether to use colors */
73export const formatSymbol: unique symbol = Symbol.for("@clo/lib/log/stack.format");
74
75/** an error stops what it happened in, and a warning only reports something */
76export type Severity = "error" | "warning";
77
78/**
79 * an error as {@linkcode format} lays it out, from {@linkcode describe}. it is
80 * plain data, so it can be drawn elsewhere, like on a page.
81 */
82export interface Diagnostic {
83 /**
84 * what is said before the colon: the severity, or else the error's name,
85 * which says more than `Error` or `TypeError` would
86 */
87 label: string;
88 severity: Severity;
89 message: string;
90 /**
91 * what it is about: the place its `file`, `line`, and `col` fields name,
92 * or else the first frame outside of a runtime or a package
93 */
94 location: Excerpt | null;
95 /** its other fields, each as json */
96 fields: Array<[name: string, json: string]>;
97 /** its `notes` field, and then the frames of its stack */
98 notes: Note[];
99 cause: Diagnostic | null;
100 /** an `AggregateError`'s errors */
101 errors: Diagnostic[];
102 /** more errors like it, of which only their message and where they came from are said */
103 similar: Array<{ message: string; from: string }>;
104}
105
106export interface Note {
107 text: string;
108 /** the place it points at */
109 location: Excerpt | null;
110 /** frames of a stack, which a page shows folded */
111 frames: Excerpt[];
112}
113
114/** a place in source code, with the code there */
115export interface Excerpt {
116 /** as shown: relative to the root inside of it, or else a path or url. `null` for native code */
117 file: string | null;
118 line: number | null;
119 col: number | null;
120 /** the function, for a frame of a stack */
121 fn: string | null;
122 /** the lines of the file from `first`, around `line`. none when the source can't be read */
123 code: string[];
124 first: number;
125 /** what on `line` it points at, as offsets into the line */
126 span: [start: number, end: number] | null;
127 /** a runtime's or a package's code, which matters less */
128 internal: boolean;
129}
130
131export interface DescribeOptions {
132 /** @default "error" */
133 severity?: Severity;
134 /**
135 * paths inside of it are shown relative to it, and a relative `file` field
136 * is read from it. defaults to the working directory.
137 */
138 root?: string;
139}
140
141/**
142 * lay out an error as {@linkcode format} prints it. the source code of each
143 * place is read as it is now, and the place an error is about shows the lines
144 * around it as well.
145 */
146export function describe(error: unknown, options: DescribeOptions = {}): Diagnostic {
147 const severity = options.severity ?? "error";
148 const root = options.root ?? node.process?.cwd() ?? null;
149 const sources = new Map<string, string[] | null>();
150 const seen = new Set<unknown>();
151 const excerpt = (frame: Frame, context: number) => toExcerpt(frame, context, root, sources);
152 // a `width` says how much of the line is meant, where a column alone points at a word
153 const place = ({ file, line, col, width }: Record<string, unknown>, context: number) => {
154 const at = excerpt({ fn: null, file: file as string, line: num(line), col: num(col) }, context);
155 if (at.span && at.col && num(width)) at.span = [at.col - 1, at.col - 1 + num(width)!];
156 return at;
157 };
158
159 const visit = (value: unknown): Diagnostic => {
160 seen.add(value);
161 const object = typeof value === "object" && value !== null ? value as Record<string, unknown> : null;
162 const name = typeof object?.name === "string" ? object.name : "";
163 const message = value instanceof Error ? value.message : typeof value === "string" ? value : json(value);
164 // a `file` field says what the error is about, which can be other than where it was thrown
165 const about = object && typeof object.file === "string" ? object : null;
166
167 const fields: Diagnostic["fields"] = [];
168 for (const [key, field] of Object.entries(object ?? {})) {
169 if (["name", "message", "stack", "cause", "errors"].includes(key)) continue;
170 if (about && ["file", "line", "col", "width"].includes(key)) continue;
171 if (key === "notes" && Array.isArray(field)) continue;
172 fields.push([key, json(field)]);
173 }
174
175 const notes: Note[] = [];
176 for (const note of Array.isArray(object?.notes) ? object.notes as unknown[] : []) {
177 const fields = (typeof note === "object" && note !== null ? note : {}) as Record<string, unknown>;
178 if (typeof fields.text !== "string") continue;
179 notes.push({
180 text: fields.text,
181 location: typeof fields.file === "string" ? place(fields, 0) : null,
182 frames: [],
183 });
184 }
185
186 let frames = value instanceof Error ? parse(value) ?? [] : [];
187 if (node.isServer && ("Bun" in globalThis)) {
188 frames = frames.filter((frame) =>
189 frame.file !== "native"
190 || !["moduleEvaluation", "loadAndEvaluateModule", "processTicksAndRejections"].includes(frame.fn ?? "")
191 );
192 }
193 const stack = frames.map((frame) => excerpt(frame, 0));
194 let location: Excerpt | null = null;
195 if (about) {
196 location = place(about, 1);
197 if (stack.length > 0) notes.push({ text: "thrown here", location: null, frames: stack });
198 } else if (frames.length > 0) {
199 const at = Math.max(0, stack.findIndex((frame) => !frame.internal));
200 location = excerpt(frames[at]!, 1);
201 if (at > 0) notes.push({ text: "thrown inside", location: null, frames: stack.slice(0, at) });
202 if (at + 1 < stack.length) notes.push({ text: "called from", location: null, frames: stack.slice(at + 1) });
203 }
204
205 const cause = object && object.cause !== undefined && !seen.has(object.cause) ? visit(object.cause) : null;
206 return {
207 label: name && name !== "Error" && name !== "TypeError" ? name : severity,
208 severity,
209 message,
210 location,
211 fields,
212 notes,
213 cause,
214 errors: value instanceof AggregateError ? value.errors.filter((inner) => !seen.has(inner)).map(visit) : [],
215 similar: [],
216 };
217 };
218 return visit(error);
219}
220
221/**
222 * convert an error into a human readable and debuggable string.
223 *
224 * this printer starts with what went wrong and where: a line saying whether it
225 * is an error or a warning and its message, then the place it is about. each
226 * place gets two lines: the path, relative to the working directory when it
227 * is inside of it, and then one line of source code. having five lines of
228 * context is useless, since five lines is often not enough context to
229 * understand the entire block of code. one line is all you need to remind
230 * yourself what the code on that line was doing. and if you can see every
231 * frame's source code, you can much more easily figure out what the bug is
232 * before opening the editor. the stack comes after, as a note.
233 *
234 * to make understanding runtime code easier, `node:*` and `ext:deno` paths are
235 * resolved into GitHub source code URLs, and node builtins will also display
236 * their source code inline (via `node:fs` and `process.binding("natives")`).
237 *
238 * with zero external imports, it is safely included as a default error
239 * formatter. it is used not only in `lib/log.ts`, but also in the headless
240 * logger.
241 */
242export function format(error: Error, colors = false, options?: DescribeOptions): string {
243 // what an AggregateError holds can be any value
244 if (typeof error === "object" && error !== null && formatSymbol in error) {
245 return (error as Error & { [formatSymbol](colors: boolean): string })[formatSymbol](colors);
246 }
247 return formatDiagnostic(describe(error, options), colors);
248}
249
250/** print what {@linkcode describe} laid out, like {@linkcode format} */
251export function formatDiagnostic(diagnostic: Diagnostic, colors = false): string {
252 const paint = (code: string, text: string) => colors && code && text ? ansi.style(code, text) : text;
253 const tone = diagnostic.severity === "error" ? ansi.fgRed : ansi.fgYellow;
254 const head = (label: string, text: string, code: string) =>
255 paint(code + ansi.bold, label) + paint(ansi.dim, ":") + " " + text + "\n";
256
257 let out = head(diagnostic.label, diagnostic.message, tone);
258 if (diagnostic.location) out += formatExcerpt(diagnostic.location, colors, tone) + "\n";
259 for (const [name, json] of diagnostic.fields) out += ` ${name}${paint(ansi.dim, ":")} ${json}\n`;
260 for (const note of diagnostic.notes) {
261 out += head("note", note.text, "");
262 if (note.location) out += formatExcerpt(note.location, colors, tone) + "\n";
263 for (const frame of note.frames) out += formatExcerpt(frame, colors, tone) + "\n";
264 }
265 if (diagnostic.cause) out += head("note", "caused by", "") + formatDiagnostic(diagnostic.cause, colors);
266 for (const error of diagnostic.errors) out += formatDiagnostic(error, colors);
267 if (diagnostic.similar.length > 0) {
268 out += paint(ansi.dim, `and ${diagnostic.similar.length} more like it`) + "\n";
269 for (const { message, from } of diagnostic.similar) out += ` ${message} ${paint(ansi.dim, from)}\n`;
270 }
271 return out;
272}
273
274/** format a single stack trace frame. see `format` for more details. */
275export function formatFrame(frame: Frame, colors: boolean): string {
276 return formatExcerpt(toExcerpt(frame, 0, node.process?.cwd() ?? null, new Map()), colors, ansi.fgRed);
277}
278
279/** a place, and its line of code with what it points at in `tone` */
280function formatExcerpt(
281 { file, line, col, fn, code, first, span, internal }: Excerpt,
282 colors: boolean,
283 tone: string,
284): string {
285 const paint = (style: string, text: string) => colors && style && text ? ansi.style(style, text) : text;
286 let out = file === null ? paint(ansi.dim, "[unknown source]") : paint(internal ? ansi.dim : "", file);
287 if (file !== null && line) {
288 const position = file.startsWith("https://") ? `#L${line}` : `:${line}${col ? `:${col}` : ""}`;
289 out += paint(ansi.dim + ansi.fgYellow, position);
290 }
291 if (fn) out += " at " + paint(internal ? ansi.dim : ansi.fgCyan, fn);
292
293 const text = line === null ? undefined : code[line - first];
294 if (text === undefined) return out;
295 if (!colors || !span) return out + "\n" + text;
296 // the line is dim, and what it points at is bold
297 const [start, end] = span;
298 const color = internal ? "" : tone;
299 return out + "\n" + paint(color + ansi.dim, text.slice(0, start))
300 + paint(color + (internal ? ansi.dim : ansi.bold), text.slice(start, end))
301 + paint(color + ansi.dim, text.slice(end));
302}
303
304/** a frame as a place, with `context` lines of its source above and below */
305function toExcerpt(
306 { fn, file, line, col }: Frame,
307 context: number,
308 root: string | null,
309 sources: Map<string, string[] | null>,
310): Excerpt {
311 const path = node.builtin("path");
312 // a frame of evaluated code has no file
313 if (!file || file === "native") file = null;
314 if (file?.startsWith("file://")) file = decodeURIComponent(new URL(file).pathname);
315 // a relative path is from the root, like a `file` field
316 if (file && path && root && !file.includes(":") && !path.isAbsolute(file)) file = path.resolve(root, file);
317 const runtime = !file || (node.isServer && /^(node|bun|internal|ext):/.test(file));
318 const internal = runtime || /[\\/]node_modules[\\/]/.test(file!);
319 // the code of bun's runtime, and the code deno transpiles, has no lines that map
320 if (runtime && ("Bun" in globalThis || ("Deno" in globalThis && file?.endsWith(".ts.js") === false))) line = null;
321
322 const lines = file && line ? memo(sources, file, () => readSource(file!)) : null;
323 const text = line ? lines?.[line - 1] : undefined;
324 const first = line ? Math.max(1, line - context) : 1;
325 return {
326 file: file && displayPath(file, root),
327 line: line || null,
328 col: col || null,
329 fn,
330 code: text === undefined ? [] : lines!.slice(first - 1, line! + context),
331 first,
332 span: text !== undefined && col ? spanAt(text, col) : null,
333 internal,
334 };
335}
336
337/** what a column points at: the word there, or the word before the symbols there */
338function spanAt(text: string, col: number): [number, number] | null {
339 if (col > text.length) return null;
340 const word = /[\p{L}\p{N}_$]/u;
341 let start = col - 1;
342 let end = col;
343 while (start > 0 && !word.test(text[start]!) && !/\s/.test(text[start]!)) start -= 1;
344 while (start > 0 && word.test(text[start - 1]!)) start -= 1;
345 while (end < text.length && word.test(text[end]!)) end += 1;
346 // `new` is about what it constructs
347 if (text.slice(start, end) === "new") end += text.slice(end).match(/^\s*[\p{L}\p{N}_$]+/u)?.[0]?.length ?? 0;
348 return [start, end];
349}
350
351/** a path relative to `root` when inside of it, or a url to the source of a runtime's own code */
352function displayPath(file: string, root: string | null): string {
353 const path = node.builtin("path");
354 const process = node.process;
355 if (process && "Bun" in globalThis && /^(node|internal):/.test(file)) {
356 return `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/${file.replace(":", "/")}.ts`;
357 }
358 if (process && file.startsWith("node:")) {
359 if ("Deno" in globalThis) {
360 return `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/node/polyfills/${file.slice(5)}.js`;
361 }
362 return `https://github.com/nodejs/node/blob/v${process.versions.node}/lib/${file.slice(5)}.js`;
363 }
364 // deno's transpiled internals have no source maps
365 if (process?.versions.deno && file.startsWith("ext:deno_") && !file.endsWith(".ts.js")) {
366 const polyfill = file.startsWith("ext:deno_node");
367 const name = file.slice(polyfill ? "ext:deno_node/".length : "ext:deno_".length);
368 return `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/`
369 + (polyfill ? `node/polyfills/${name.endsWith(".ts.js") ? name.slice(0, -3) : name}` : name);
370 }
371 if (!path || !root || !path.isAbsolute(file)) return file;
372 const relative = path.relative(root, file);
373 return relative.startsWith("..") || path.isAbsolute(relative) ? file : relative;
374}
375
376/** the lines of a file, or of a node.js builtin */
377function readSource(file: string): string[] | null {
378 // node.js has a secret binding for all the internal source codes. bun and
379 // deno do not offer a way to get the source of their internal modules.
380 if (file.startsWith("node:")) return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null;
381 const fs = node.builtin("fs");
382 const path = node.builtin("path");
383 if (!fs || !path || !path.isAbsolute(file)) return null;
384 try {
385 return fs.readFileSync(file, "utf-8").split("\n");
386 } catch {
387 return null;
388 }
389}
390
391function json(value: unknown): string {
392 const bigint = (inner: unknown) => typeof inner === "bigint" ? `${inner}n` : inner;
393 if (typeof value === "bigint") return `${value}n`;
394 try {
395 return JSON.stringify(value, (_, inner) => bigint(inner)) ?? String(value);
396 } catch {
397 return String(value);
398 }
399}
400
401function num(value: unknown): number | null {
402 return typeof value === "number" && value > 0 ? value : null;
403}
404
405function memo<K, V>(map: Map<K, V>, key: K, create: () => V): V {
406 if (!map.has(key)) map.set(key, create());
407 return map.get(key)!;
408}
409
410function parseV8OrIe(stack: string): Frame[] {
411 return stack
412 .split("\n")
413 .filter((line) => !!line.match(regexV8Stack))
414 .map(function(line) {
415 let sanitizedLine = line
416 .replace(/^\s+/, "")
417 .replace(/\(eval code/g, "(")
418 .replace(/^.*?\s+/, "");
419
420 // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in
421 // case it has spaces in it, as the string is split on \s+ later on
422 const loc = sanitizedLine.match(/ (\(.+\)$)/);
423
424 // remove the parenthesized location from the line, if it was matched
425 sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine;
426
427 // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine
428 // because this line doesn't have function name
429 // an anonymous async frame is "async <location>"
430 const locationParts = extractLocation(loc?.[1] ?? sanitizedLine.replace(/^async /, ""));
431 const functionName = (loc && sanitizedLine) || undefined;
432 const fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1
433 ? undefined
434 : locationParts[0];
435
436 return {
437 fn: functionName ?? null,
438 file: fileName,
439 line: 0 | locationParts[1],
440 col: 0 | locationParts[2],
441 } satisfies Frame;
442 });
443}
444
445function parseJscOrSpidermonkey(stack: string): Frame[] {
446 // Using string literal "\n" does not work in Safari.
447 return stack.split(/\n/g).map((source) => {
448 let fn: string | null = "";
449 let file: string | null = null;
450 let line: number | null = null;
451 let col: number | null = null;
452 if (source.endsWith("@")) {
453 // Safari eval frames only have function names and nothing else
454 fn = source.slice(0, -1);
455 } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) {
456 // Safari eval frames only have function names and nothing else
457 fn = source.endsWith("@") ? source.slice(0, -1) : source;
458 } else {
459 const matches = source.match(regexFunctionName);
460 const functionName = matches && matches[1] ? matches[1] : undefined;
461 const locationParts = extractLocation(
462 source.replace(regexFunctionName, ""),
463 );
464 fn = functionName!;
465 file = locationParts[0];
466 line = 0 | locationParts[1];
467 col = 0 | locationParts[2];
468 }
469 if (fn === "module code") fn = null;
470 return {
471 fn,
472 file,
473 line,
474 col,
475 };
476 });
477}
478
479function extractLocation(urlLike: string) {
480 // Fail-fast but return locations like "(native)"
481 if (urlLike.indexOf(":") === -1) {
482 return [urlLike];
483 }
484
485 const parts: any = regexLocation.exec(urlLike.replace(/[()]/g, ""));
486 return [parts[1], parts[2] || undefined, parts[3] || undefined];
487}
488
489const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/;
490const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m;
491const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/;
492
493import * as node from "../node.ts";
494import * as ansi from "../string/ansi.ts";