/// import { type UnknownFn, getFirst } from "@starbeam/core-utils"; import type { DescriptionArgs, DescriptionDetails, ReactiveId, Stack as StackProtocol, StackFrame, StackFrameDisplayOptions, } from "@starbeam/interfaces"; import type * as interfaces from "@starbeam/interfaces"; import { getID } from "@starbeam/shared"; import { hasType, isObject, verified } from "@starbeam/verify"; import { default as StackTracey } from "stacktracey"; import { Description } from "./description/impl.js"; import { inspector } from "./inspect/inspect-support.js"; import { describeModule } from "./module.js"; interface ErrorWithStack extends Error { stack: string; } export interface StackStatics { readonly EMPTY: StackProtocol; create: (this: void, internal?: number) => StackProtocol; fromStack: (stack: string) => StackProtocol; from: ((error: ErrorWithStack) => StackProtocol) & ((error: unknown) => StackProtocol | null); id: ( this: void, description?: string | Description | { id: ReactiveId } ) => ReactiveId; description: ( this: void, args: DescriptionArgs & { fromUser?: | string | DescriptionDetails | interfaces.Description | undefined; }, internal?: number ) => interfaces.Description; desc: ( type: interfaces.DescriptionType, fromUser?: string | DescriptionDetails | interfaces.Description | undefined, internal?: number | undefined ) => interfaces.Description; fromCaller: (this: void, internal?: number) => StackProtocol; replaceFrames: (error: unknown, fromStack: StackProtocol) => void; /** * Erase an abstraction from the call stack so the test error points at the user code, rather than * the abstraction's code. * * Call a callback, and if the callback throws an exception, remove the current frame and all of the * frames invoked by the current frame from the call stack. * * If you want to erase additional **caller** frames (because the code that calls the callback is * not the direct call site from user code), you can specify an additional number of frames to erase * using the `internal` parameter. */ entryPoint: ( this: void, callback: () => T, options?: { internal?: number; stack?: StackProtocol } ) => T; } let PickedStack: StackStatics; const MISSING = -1; const START = 0; if (import.meta.env.DEV) { Error.stackTraceLimit = 1000; class ParsedStack { static empty(): ParsedStack { return new ParsedStack("", "", "", []); } static parse({ stack }: { stack: string }): ParsedStack { const parsed = new StackTracey(stack); const frames = parsed.items; const [firstFrame] = frames; if (firstFrame === undefined) { return new ParsedStack(stack, stack, "", []); } const first = firstFrame.beforeParse; const lines = stack.split("\n"); const offset = lines.findIndex((line) => line.trim() === first); if (offset === MISSING) { throw Error( `An assumption was incorrect: A line that came from StackTracey cannot be found in the original trace.\n\n== Stack ==\n\n${stack}\n\n== Line ==\n\n${first}` ); } // the header is all of the lines before the offset const header = lines.slice(START, offset).join("\n"); const rest = lines.slice(offset).join("\n"); return new ParsedStack( stack, header, rest, frames.map((f) => StackFrame.from(parsed, f)) ); } static { inspector(this, "ParsedStack").define((stack, debug) => debug.struct({ header: stack.#header, frames: stack.#frames.map((f) => f.parts().display()), rest: stack.#rest, }) ); } readonly #source: string; readonly #header: string; readonly #rest: string; readonly #frames: readonly StackFrame[]; private constructor( source: string, header: string, rest: string, frames: readonly StackFrame[] ) { this.#source = source; this.#header = header; this.#rest = rest; this.#frames = frames; } get header(): string { return this.#header; } get entries(): readonly StackFrame[] { return this.#frames; } /** * The formatted stack trace, suitable to be attached to `error.stack`. */ get stack(): string { return `${this.#header}\n${this.#rest}`; } replaceFrames(stack: ParsedStack): ParsedStack { return new ParsedStack( this.#source, this.#header, stack.#rest, stack.#frames ); } slice(n: number): ParsedStack { const rest = this.#rest.split("\n").slice(n).join("\n"); return new ParsedStack( this.#source, this.#header, rest, this.#frames.slice(n) ); } } const REPLACED_ERRORS = new WeakSet(); const INITIAL_INTERNAL_FRAMES = 0; const INITIAL_ENTRY_POINT_FRAMES = 1; const CALLER = 1; const ABSTRACTION_CALLER = 2; class DebugStack implements StackProtocol { static EMPTY = new DebugStack(ParsedStack.empty()); static create(this: void, internal = INITIAL_INTERNAL_FRAMES): DebugStack { const ErrorClass = Error; if ("captureStackTrace" in ErrorClass) { const err = {} as { stack: string }; ErrorClass.captureStackTrace(err, DebugStack.create); return DebugStack.fromStack(err.stack).slice(internal); } else { const stack = Error( "An error created in the internals of Stack.create" ).stack; return DebugStack.fromStack(verified(stack, hasType("string"))).slice( internal + CALLER ); } } static fromCaller( this: void, internal = INITIAL_INTERNAL_FRAMES ): DebugStack { // Remove *this* `fromCaller` frame from the stack *and* the caller's frame return DebugStack.create(internal + ABSTRACTION_CALLER); } static fromStack(stack: string): DebugStack { return new DebugStack(ParsedStack.parse({ stack })); } /** * Replace the stack frames in the specified error with the stack frames from the specified stack, * but leave the header from the specified error. * * If the error is not an Error with a stack, this function does nothing. */ static replaceFrames(error: unknown, fromStack: Stack): void { if (isErrorWithStack(error)) { const errorStack = DebugStack.from(error); errorStack.withReplacedFrames(fromStack as DebugStack); error.stack = errorStack.stack; } } static from(error: ErrorWithStack): DebugStack; static from(error: unknown): DebugStack | null; static from(error: unknown): DebugStack | null { if (isErrorWithStack(error)) { return new DebugStack(ParsedStack.parse(error)); } else { return null; } } static description( args: DescriptionArgs & { fromUser?: | string | DescriptionDetails | interfaces.Description | undefined; }, internal = INITIAL_INTERNAL_FRAMES ): interfaces.Description { const stack = DebugStack.fromCaller(internal + CALLER); const fromUser = args.fromUser; const api: string | interfaces.ApiDetails | undefined = args.api ?? {}; if (typeof api !== "string" && api.package === undefined) { const starbeam = stack.caller?.starbeamCaller; if (starbeam) { api.package = starbeam.package; api.name = starbeam.name; } } if (fromUser === undefined || typeof fromUser === "string") { return Description.from({ ...args, stack }); } else if (Description.is(fromUser)) { return fromUser.withId(args.id); } else { return Description.from({ ...args, stack }); } } static desc( type: interfaces.DescriptionType, fromUser?: | string | DescriptionDetails | interfaces.Description | undefined, internal = INITIAL_INTERNAL_FRAMES ): interfaces.Description { return DebugStack.description( { type, fromUser, }, internal + CALLER ); } static id( this: void, description?: string | { id: ReactiveId } ): ReactiveId { if (description === undefined || typeof description === "string") { return getID(); } else { return description.id; } } static callerFrame( internal = INITIAL_INTERNAL_FRAMES ): StackFrame | undefined { return DebugStack.fromCaller(internal + CALLER).caller; } static entryPoint( callback: () => T, { internal = INITIAL_ENTRY_POINT_FRAMES, stack = DebugStack.create(CALLER + internal), }: { internal?: number; stack?: StackProtocol } = {} ): T { try { return callback(); } catch (e) { if (isErrorWithStack(e) && !REPLACED_ERRORS.has(e)) { const errorStack = DebugStack.from(e); const updated = errorStack.withReplacedFrames(stack as DebugStack); e.stack = updated.stack; REPLACED_ERRORS.add(e); } throw e; } } #parsed: ParsedStack; constructor(parsed: ParsedStack) { this.#parsed = parsed; } get entries(): readonly StackFrame[] { return this.#parsed.entries; } get caller(): StackFrame | undefined { return getFirst(this.#parsed.entries); } get header(): string { return this.#parsed.header; } /** * The formatted stack trace, suitable to be attached to `error.stack`. */ get stack(): string { return this.#parsed.stack; } /** * Replace the stack frames with the current Stack with the frames from the given Stack, but keep * the same header. */ withReplacedFrames(stack: DebugStack): DebugStack { return new DebugStack(this.#parsed.replaceFrames(stack.#parsed)); } slice(n: number): DebugStack { if (n === START) { return this; } else { return new DebugStack(this.#parsed.slice(n)); } } } PickedStack = DebugStack; class StackFrame implements interfaces.StackFrame { static from(stack: StackTracey, frame: StackTracey.Entry): StackFrame { return new StackFrame(stack, frame, null); } static { inspector(this, "StackFrame").define((frame, debug) => debug.struct({ original: frame.parts().display() }) ); } #stack: StackTracey; #frame: StackTracey.Entry; #reified: StackTracey.Entry | null; private constructor( stack: StackTracey, frame: StackTracey.Entry, reified: StackTracey.Entry | null ) { this.#stack = stack; this.#frame = frame; this.#reified = reified; } get starbeamCaller(): | { package: string; name?: string | undefined } | undefined { const frame = this.#reify(); const pkg = /(@starbeam\/[/]+)/.exec(frame.file) as | (RegExpExecArray & [string, string]) | undefined; if (pkg) { return { // eslint-disable-next-line @typescript-eslint/no-magic-numbers package: pkg[1], name: frame.callee === "" ? undefined : frame.callee, }; } } get action(): string { return this.#reify().callee; } get loc(): { line: number; column?: number | undefined } | undefined { const entry = this.#reify(); if (entry.line === undefined) { return undefined; } return { line: entry.line, column: entry.column }; } get debug(): StackTracey.Entry { return this.#reify(); } #reify(): StackTracey.Entry { let reified = this.#reified; if (!reified) { this.#reified = reified = this.#stack.withSource(this.#frame); } return reified; } link(options?: StackFrameDisplayOptions): string { if (options?.complete) { return this.#stack.items.map((entry) => entry.beforeParse).join("\n"); } const module = describeModule(this.#reify().file); return module.display({ loc: this.loc }, options); } fullStack(): string { return this.#stack.asTable(); } display(options?: StackFrameDisplayOptions): string { const module = describeModule(this.#reify().file); return module.display({ action: this.action, loc: this.loc }, options); } parts( options?: StackFrameDisplayOptions | undefined ): interfaces.DisplayParts { const module = describeModule(this.#reify().file); return module.parts({ action: this.action, loc: this.loc }, options); } } } else { /** * A stub implementation of the `Stack` infrastructure that doesn't do anything. */ class ProdStack implements StackProtocol { static EMPTY = new ProdStack(); static create(this: void): StackProtocol { return ProdStack.EMPTY; } static fromStack(this: void): StackProtocol { return ProdStack.EMPTY; } static from(error: ErrorWithStack): StackProtocol; static from(error: unknown): StackProtocol | null; static from(): StackProtocol | null { return ProdStack.EMPTY; } static replaceFrames(): void { return; } static description( args: DescriptionArgs & { fromUser?: | string | DescriptionDetails | interfaces.Description | undefined; } ): interfaces.Description { return Description.from({ ...args, stack: ProdStack.EMPTY }); } static desc( type: interfaces.DescriptionType, fromUser?: | string | DescriptionDetails | interfaces.Description | undefined ): interfaces.Description { return ProdStack.description({ type, stack: ProdStack.EMPTY, fromUser, }); } static id(): ReactiveId { return getID(); } static callerFrame(): StackFrame | undefined { return undefined; } static fromCaller(): StackProtocol { return ProdStack.EMPTY; } static entryPoint(callback: () => T): T { return callback(); } readonly caller: StackFrame | undefined = undefined; readonly stack: string = ""; } PickedStack = ProdStack; } export const Stack: StackStatics = PickedStack; export type Stack = interfaces.Stack; export const entryPoint = PickedStack.entryPoint; export function entryPointFn( fn: F, options?: { stack: Stack } ): F { return ((...args: unknown[]) => entryPoint(() => fn(...args), { internal: 1, ...options })) as F; } export function entryPoints( funcs: Funcs, options?: { stack: Stack } ): Funcs { const result = Object.create(null) as Record; for (const [key, fn] of Object.entries(funcs) as [string, UnknownFn][]) { if (typeof fn === "function") { result[key] = entryPointFn(fn, options); } else { result[key] = fn; } } return result as Funcs; } /** This should be convertable to something like Description.EMPTY in prod builds */ export const descriptionFrom = PickedStack.description; export const Desc = PickedStack.desc; /** * If it isn't already removed, this should be convertable to getID in prod builds */ export const idFrom = PickedStack.id; export const callerStack: (this: void, internal?: number) => StackProtocol = PickedStack.fromCaller; export function isErrorWithStack(error: unknown): error is ErrorWithStack { return ( isObject(error) && error instanceof Error && typeof error.stack === "string" ); }