UNPKG

@steve02081504/virtual-console

Version:

A virtual console for capturing and manipulating terminal output.

554 lines (502 loc) 23 kB
/** * 从 `serializeArgSnapshot` 产出的带 `kind` 标签树生成 plain / ANSI 展示文本。 */ import { DEFAULT_SNAPSHOT_DEPTH } from '../core/snapshot.mjs' import { parseStackTraceLine, stackFrameToOsc8Href } from '../core/stack.mjs' import { ansiHyperlink, stripTerminalDecorations } from './ansi.mjs' /** * @typedef {object} FormatSnapshotOptions * @property {number} [depth=Infinity] - 对象展开最大深度(超过则输出 `[Object]` 风格占位)。 * @property {string} [indent='\t'] - 多行结构的缩进单元。 * @property {boolean} [colorize=true] - ANSI 路径是否着色;plain 路径忽略。 */ /** * @param {import('../shared.d.mts').ArgSnapshot} snap - 任意快照子树。 * @param {FormatSnapshotOptions} [options] - 格式选项。 * @returns {string} 无 ANSI、可搜索的纯文本。 */ export function formatSnapshotPlain(snap, options = {}) { return formatSnapshotInner(snap, { ...options, colorize: false }) } /** * @param {import('../shared.d.mts').ArgSnapshot} snap - 任意快照子树。 * @param {FormatSnapshotOptions} [options] - 格式选项。 * @returns {string} 终端 ANSI 文本;`colorize: false` 时剥离 CSI,等价纯文本。 */ export function formatSnapshotAnsi(snap, options = {}) { const raw = formatSnapshotInner(snap, { depth: Infinity, colorize: true, ...options }) if (options.colorize === false) return stripTerminalDecorations(raw) return raw } /** * 合并 `console.dir` 浅层选项与渲染默认值。 * @param {import('../shared.d.mts').DirOptionsPayload | undefined} dirOpts - 片段上的 `dirOptions`。 * @param {{ depth: number; colorize: boolean }} fallback - 默认值。 * @returns {{ depth: number; colorize: boolean }} `formatSnapshot*` 使用的深度与是否着色。 */ export function mergeDirOptionsForRender(dirOpts, fallback = { depth: DEFAULT_SNAPSHOT_DEPTH, colorize: true }) { if (!dirOpts) return fallback return { depth: dirOpts.depth ?? fallback.depth, colorize: fallback.colorize && dirOpts.colors, // 若环境不支持 ANSI,片段也无法启用着色 } } /** * @param {{ dirOptions?: import('../shared.d.mts').DirOptionsPayload }} segment - `kind: 'value'` 片段。 * @param {boolean} supportsAnsi - 条目级 ANSI 开关。 * @returns {{ depth: number; colorize: boolean }} `formatSnapshot*` 使用的深度与是否着色。 */ export function resolveValueRenderOptions(segment, supportsAnsi) { return mergeDirOptionsForRender(segment.dirOptions, { depth: Infinity, colorize: supportsAnsi, }) } /** * 选择需要转义最少的引号字符,尽量贴近 util.inspect 的字符串字面量风格。 * @param {string} str - 原始字符串。 * @returns {"'" | '"' | '`'} 冲突最少的引号字符。 */ function pickBestQuote(str) { const singleCount = (str.match(/'/g) || []).length const doubleCount = (str.match(/"/g) || []).length const backtickCount = (str.match(/`/g) || []).length if (singleCount <= doubleCount && singleCount <= backtickCount) return '\'' if (doubleCount <= singleCount && doubleCount <= backtickCount) return '"' return '`' } /** * 把 JS 字符串转为 inspect 风格字面量(自动选引号,控制字符不直出)。 * @param {unknown} raw - 原始值。 * @returns {string} 带转义的 inspect 风格字面量。 */ function quoteSingleJsString(raw) { const value = String(raw) const quote = pickBestQuote(value) let out = '' for (let i = 0; i < value.length; i += 1) { const ch = value[i] if (ch === '\\') { out += '\\\\' continue } if (ch === quote) { out += `\\${quote}` continue } if (quote === '`' && ch === '$' && value[i + 1] === '{') { out += '\\${' i += 1 continue } if (ch === '\r') out += '\\r' else if (ch === '\n') out += '\\n' else if (ch === '\t') out += '\\t' else if (ch === '\b') out += '\\b' else if (ch === '\f') out += '\\f' else if (ch === '\v') out += '\\v' else if (ch === '\0') out += '\\0' else out += ch } return `${quote}${out}${quote}` } /** * Error 首行:`Name` 高亮,存在 `message` 时用红色显示消息文本。 * 无栈时整段「类型 + `: ` + message」包在一对 `[]` 内,如 `[ReferenceError: exit is not defined]`。 * @param {string} name - 错误名。 * @param {string} msg - message。 * @param {{ reset: string; yellow: string; red: string; grey?: string }} colors - 着色片段。 * @param {boolean} [bracketName=false] - 为 true 时输出 `[Name: msg]`(无 msg 时 `[Name]`)。 * @returns {string} 一行 ANSI 标题(可选括号包裹)。 */ function formatErrorHeaderAnsi(name, msg, colors, bracketName = false) { const bold = '\x1b[1m' const grey = colors.grey || '' return `${bold}${bracketName ? `${grey}[` : ''}${colors.yellow}${name}${msg ? `${grey}: ${colors.red}${msg}` : ''}${bracketName ? `${grey}]` : ''}${colors.reset}` } /** * 组装 Error 展示用正文:标题行 + 栈 `raw` 行(已去重)。 * @param {string} name - 错误名。 * @param {string} msg - message。 * @param {import('../shared.d.mts').StackFrame[]} frames - 解析帧。 * @returns {string} 纯文本多行栈体。 */ function buildErrorStackBodyText(name, msg, frames) { const headLinePlain = `${name}${msg ? `: ${msg}` : ''}` if (!frames.length) return `[${headLinePlain}]` return `${headLinePlain}\n${frames.map(f => f.raw).join('\n')}` } /** * enumerable 字段包一层 `{}`(与 inspect 中带自有属性的 Error 形似)。 * 栈帧后若有 enumerable:` …最后一行 at …` + 空格 + `{`,否则换行后 `{`。 * @param {string} extra - `\\n key: …` 或空。 * @param {{ openAfterStack?: boolean }} [opts] - 接在最后一行栈帧后时用空格再接 `{`。 * @returns {string} 带 `{}` 包裹的 enumerable 块或空串。 */ function wrapErrorEntriesPlain(extra, opts = {}) { const inner = extra.replace(/^\n+/, '').trimEnd() if (!inner) return '' const open = opts.openAfterStack ? ' {' : '\n{' return `${open}\n${inner}\n}` } /** * @param {string} extra - 已格式化的 enumerable(可含 ANSI)。 * @param {{ reset: string; grey: string }} colors - 括号用灰色。 * @param {{ openAfterStack?: boolean }} [opts] - 与 {@link wrapErrorEntriesPlain} 一致。 * @returns {string} ANSI 版 `{}` 包裹块或空串。 */ function wrapErrorEntriesAnsi(extra, colors, opts = {}) { const inner = extra.replace(/^\n+/, '').trimEnd() if (!inner) return '' const open = opts.openAfterStack ? ` ${colors.grey}{${colors.reset}` : `\n${colors.grey}{${colors.reset}` return `${open}\n${inner}\n${colors.grey}}${colors.reset}` } /** * 路径后的 `:line:col` 与收尾括号等着色。 * @param {string} tail - 路径结束后的子串(含 `:line:col` 及后续)。 * @param {{ reset: string; dim: string; yellow: string; magenta: string }} colors - 着色片段。 * @param {string} baseAnsi - 行号前使用的复位/弱色前缀(与 `at` 后段一致)。 * @returns {string} 行号列号等着色后的尾部。 */ function ansiColorLocationTail(tail, colors, baseAnsi) { const match = tail.match(/^(:)(\d+)(:)(\d+)([\S\s]*)$/) if (!match) return `${colors.yellow}${tail}` return `${baseAnsi}${match[1]}${colors.magenta}${match[2]}${baseAnsi}${match[3]}${colors.magenta}${match[4]}${baseAnsi}${match[5]}${colors.reset}` } /** * 行首 `\\s*at\\s+`:`at` 用灰色,两侧用 low。 * @param {string} lineStart - 从行首到函数名/路径前的片段(含 `at`)。 * @param {{ reset: string; grey: string }} colors - 着色片段。 * @param {string} low - 底色。 * @returns {string} 灰显 `at` 关键词后的行首片段。 */ function ansiPaintAtPrefix(lineStart, colors, low) { const match = /^(\s*)(at)(\s+)/.exec(lineStart) if (!match) return `${low}${lineStart}` return `${low}${match[1]}${colors.grey}${match[2]}${low}${match[3]}` } /** * 单行栈:`parsedFrame` 优先;底色用默认前景(不用 dim);`at` 仅淡灰、函数青、路径黄、行号洋红。 * 无 OSC8 时仍做分段着色,仅不包超链接。 * OSC8 仅包住去掉行首空白后的正文,缩进空格在链接外(仍为默认前景着色)。 * @param {string} line - 原始一行。 * @param {{ reset: string; yellow: string; cyan: string; grey: string; magenta: string }} colors - 着色片段。 * @param {import('../shared.d.mts').StackFrame | undefined} parsedFrame - 可选。 * @returns {string} 着色后的单行栈文本(可含 OSC8 包裹)。 */ function formatErrorStackLineAnsi(line, colors, parsedFrame) { const baseAnsi = colors.reset const { reset } = colors const leadingSpaces = line.match(/^\s*/)[0] const contentLine = line.slice(leadingSpaces.length) const indentOut = leadingSpaces ? `${baseAnsi}${leadingSpaces}${reset}` : '' const frame = parsedFrame ?? parseStackTraceLine(line) const href = stackFrameToOsc8Href(frame) const { filePath } = frame const canPaintPath = Boolean(filePath && frame.line > 0) /** * 无有效路径时仅对 `at` 前缀做淡灰处理(不含行首缩进;缩进由调用方前缀)。 * @returns {string} `contentLine` 上的基础着色结果。 */ const paintPlainLine = () => { const atMatch = /^(\s*)(at)(\s+)/.exec(contentLine) if (atMatch) return `${baseAnsi}${atMatch[1]}${colors.grey}${atMatch[2]}${baseAnsi}${atMatch[3]}${contentLine.slice(atMatch[0].length)}${reset}` return `${baseAnsi}${contentLine}${reset}` } if (!canPaintPath) return indentOut + paintPlainLine() const pathIndex = contentLine.lastIndexOf(filePath) if (pathIndex < 0) { const plain = paintPlainLine() return indentOut + (href ? ansiHyperlink(href, plain) : plain) } const head = contentLine.slice(0, pathIndex) const tail = contentLine.slice(pathIndex + filePath.length) const coloredTail = ansiColorLocationTail(tail, colors, baseAnsi) const atMatch = /^\s*at\s+/.exec(head) if (atMatch) { const fnStart = atMatch.index + atMatch[0].length const fnEnd = head.indexOf(' (', fnStart) if (fnEnd > fnStart) { const preAtThroughSpaces = head.slice(0, fnStart) const fn = head.slice(fnStart, fnEnd) const mid = head.slice(fnEnd) const paintedPre = ansiPaintAtPrefix(preAtThroughSpaces, colors, baseAnsi) const visible = `${paintedPre}${colors.cyan}${fn}${baseAnsi}${mid}${colors.yellow}${filePath}${coloredTail}` return indentOut + (href ? ansiHyperlink(href, visible) : visible) } } const headMatch = /^(\s*)(at)(\s+)([\S\s]*)$/.exec(head) const paintedHead = headMatch ? `${baseAnsi}${headMatch[1]}${colors.grey}${headMatch[2]}${baseAnsi}${headMatch[3]}${headMatch[4]}` : `${baseAnsi}${head}` const visible = `${paintedHead}${colors.yellow}${filePath}${coloredTail}` return indentOut + (href ? ansiHyperlink(href, visible) : visible) } /** * 快照:`stack` 为帧数组;首行始终由 `name`/`message` 着色生成,栈行用解析帧着色。 * @param {string} name - 错误名。 * @param {string} msg - message。 * @param {import('../shared.d.mts').StackFrame[]} frames - 解析帧。 * @param {string} extraRendered - 附加字段。 * @param {{ reset: string; dim: string; yellow: string; red: string; cyan: string }} colors - 着色片段。 * @returns {string} 含标题、栈行与 enumerable 块的整段 ANSI。 */ function formatErrorSnapshotAnsiFromFrames(name, msg, frames, extraRendered, colors) { const wrappedNoStack = wrapErrorEntriesAnsi(extraRendered, colors, { openAfterStack: false }) const bracketHeader = !frames.length const headerLine = formatErrorHeaderAnsi(name, msg, colors, bracketHeader) if (!frames.length) return `${headerLine}${wrappedNoStack}` const wrappedExtra = wrapErrorEntriesAnsi(extraRendered, colors, { openAfterStack: true }) const rest = frames.map(frame => formatErrorStackLineAnsi(frame.raw, colors, frame)).join('\n') return `${headerLine}\n${rest}${wrappedExtra}` } /** * 将 Date 快照的毫秒值格式化为与 `util.inspect` 一致的展示文本。 * wire 反序列化后无效 Date 的 `NaN` 会变成 `null`。 * @param {unknown} ms - `Date#getTime()` 或 JSON 还原后的值。 * @returns {string} ISO 字符串或 `Invalid Date`。 */ function formatDateSnapshotValue(ms) { if (typeof ms !== 'number' || Number.isNaN(ms)) return 'Invalid Date' try { return new Date(/** @type {number} */ ms).toISOString() } catch { return 'Invalid Date' } } /** * @param {unknown} snap - 任意快照。 * @param {FormatSnapshotOptions} options - 格式选项(含 `depth`、`colorize`)。 * @returns {string} 单棵快照树对应的展示文本。 */ function formatSnapshotInner(snap, options) { const colorize = options.colorize !== false const depthLimit = options.depth ?? Infinity const indentUnit = typeof options.indent === 'string' ? options.indent : '\t' const colors = colorize ? { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', grey: '\x1b[90m', magenta: '\x1b[35m', red: '\x1b[31m', dim: '\x1b[2m', } : { reset: '', green: '', yellow: '', cyan: '', grey: '', magenta: '', red: '', dim: '', } /** * @param {unknown} snapshotNode - 快照树上的节点(或兜底的非对象原语)。 * @param {number} objectDepth - 对象深度。 * @returns {string} 该节点在 plain/ANSI 下的展示串(不含 `&lt;ref *N&gt;` 前缀;外层 `formatNode` 负责前缀)。 */ function formatNodeImpl(snapshotNode, objectDepth) { /** * 装箱对象自有属性:`[Number: 0] { x: 1 }` 中的 `{ … }`。 * @param {Array<{ key: string; value: unknown }>} entries - 自有枚举属性。 * @returns {string} 自有枚举属性展示串。 */ function formatOwnEntriesTail(entries) { if (!Array.isArray(entries) || !entries.length) return '' if (objectDepth >= depthLimit) return '' const compactLines = entries.map(entry => { const { key, value: val } = /** @type {{ key: string; value: unknown }} */ entry let keyStr = key if (!/^[$A-Z_a-z][\w$]*$/.test(keyStr)) keyStr = `'${keyStr.replaceAll('\'', '\\\'').replaceAll('\n', '\\n')}'` return `${keyStr}: ${formatNode(val, objectDepth + 1)}` }) const compactInner = compactLines.join(', ') if (!compactInner.includes('\n') && compactInner.length <= 120) return ` { ${compactInner} }` const spaces = indentUnit.repeat(objectDepth + 1) const nextIndent = indentUnit.repeat(objectDepth) const lines = entries.map(entry => { const { key, value: val } = /** @type {{ key: string; value: unknown }} */ entry let keyStr = key if (!/^[$A-Z_a-z][\w$]*$/.test(keyStr)) keyStr = `'${keyStr.replaceAll('\'', '\\\'').replaceAll('\n', '\\n')}'` return `${spaces}${keyStr}: ${formatNode(val, objectDepth + 1)}` }) return ` {\n${lines.join(',\n')}\n${nextIndent}}` } if (snapshotNode == null || typeof snapshotNode !== 'object') return colors.grey + String(snapshotNode) + colors.reset const node = /** @type {Record<string, unknown>} */ snapshotNode if (node.kind === 'truncated') { const rawLabel = String(node.label ?? 'Object') const bracketed = rawLabel.startsWith('[') && rawLabel.endsWith(']') ? rawLabel : `[${rawLabel}]` return `${colors.cyan}${bracketed}${colors.reset}` } if (node.kind === 'null') return `${colors.reset}null${colors.reset}` if (node.kind === 'string') return `${colors.green}${quoteSingleJsString(node.value)}${colors.reset}` if (node.kind === 'number') return `${colors.yellow}${node.value}${colors.reset}` if (node.kind === 'boolean') return `${colors.yellow}${node.value}${colors.reset}` if (node.kind === 'undefined') return `${colors.grey}undefined${colors.reset}` if (node.kind === 'bigint') return `${colors.yellow}${node.value}n${colors.reset}` if (node.kind === 'symbol') return `${colors.green}${node.value}${colors.reset}` if (node.kind === 'function') { const n = String(node.value ?? '(anonymous)') if (node.isClass === true) return `${colors.cyan}[class ${n}]${colors.reset}` if (n === '(anonymous)') return `${colors.cyan}[Function (anonymous)]${colors.reset}` return `${colors.cyan}[Function: ${n}]${colors.reset}` } if (node.kind === 'unknown') return `${colors.grey}${node.value}${colors.reset}` if (node.kind === 'circular') { const circularRefIndex = /** @type {{ refId?: number; value?: unknown }} */ node.refId const text = typeof circularRefIndex === 'number' ? `[Circular *${circularRefIndex}]` : String(/** @type {{ value?: unknown }} */ node.value ?? '[Circular]') return `${colors.cyan}${text}${colors.reset}` } if (node.kind === 'Date') return `${colors.magenta}${formatDateSnapshotValue(node.value)}${colors.reset}` if (node.kind === 'RegExp') return `${colors.red}${node.value}${colors.reset}` if (node.kind === 'Number' && typeof node.boxedText === 'string') { const inner = `Number: ${node.boxedText}` const head = colorize ? `${colors.yellow}[${inner}]${colors.reset}` : `[${inner}]` return head + formatOwnEntriesTail(/** @type {Array<{ key: string; value: unknown }>} */ node.entries) } if (node.kind === 'Boolean' && typeof node.boxedText === 'string') { const inner = `Boolean: ${node.boxedText}` const head = colorize ? `${colors.yellow}[${inner}]${colors.reset}` : `[${inner}]` return head + formatOwnEntriesTail(/** @type {Array<{ key: string; value: unknown }>} */ node.entries) } if (node.kind === 'String' && typeof node.boxedString === 'string') { const inner = `String: ${quoteSingleJsString(node.boxedString)}` const head = colorize ? `${colors.yellow}[${inner}]${colors.reset}` : `[${inner}]` return head + formatOwnEntriesTail(/** @type {Array<{ key: string; value: unknown }>} */ node.entries) } if (node.kind === 'Error') { const name = String(node.name ?? 'Error') const msg = String(node.message ?? '') const extra = Array.isArray(node.entries) && node.entries.length ? '\n' + node.entries.map(entry => { const { key, value: val } = /** @type {{ key: string; value: unknown }} */ entry return ` ${key}: ${formatNode(val, objectDepth)}` }).join('\n') : '' const frames = /** @type {import('../shared.d.mts').StackFrame[]} */ node.stack const bodyText = buildErrorStackBodyText(name, msg, frames) if (!colorize) return bodyText + wrapErrorEntriesPlain(extra, { openAfterStack: frames.length }) return formatErrorSnapshotAnsiFromFrames(name, msg, frames, extra, colors) } if (node.kind === 'Map') { if (objectDepth >= depthLimit) return `${colors.cyan}[Map]${colors.reset}` const items = /** @type {Array<{ key: unknown; value: unknown }>} */ node.items || [] if (!items.length) return `${colors.cyan}Map(0) {}${colors.reset}` const lines = items.map(({ key, value: val }) => { const ks = formatNode(key, objectDepth + 1) const vs = formatNode(val, objectDepth + 1) return `${indentUnit}${ks} => ${vs}` }) return `${colors.cyan}Map(${items.length}) ${colors.reset}{\n${lines.join(',\n')}\n}` } if (node.kind === 'Set') { if (objectDepth >= depthLimit) return `${colors.cyan}[Set]${colors.reset}` const items = /** @type {unknown[]} */ node.items || [] if (!items.length) return `${colors.cyan}Set(0) {}${colors.reset}` const lines = items.map(el => `${indentUnit}${formatNode(el, objectDepth + 1)}`) return `${colors.cyan}Set(${items.length}) ${colors.reset}{\n${lines.join(',\n')}\n}` } if (node.kind === 'array') { if (objectDepth >= depthLimit) return `${colors.cyan}[Array]${colors.reset}` const items = /** @type {unknown[]} */ node.items || [] if (!items.length) return '[]' const rendered = items.map(el => formatNode(el, objectDepth + 1)) const compactInner = rendered.join(', ') if (!compactInner.includes('\n') && compactInner.length <= 80) return `[ ${compactInner} ]` const spaces = indentUnit.repeat(objectDepth + 1) const inner = rendered.map(el => `${spaces}${el}`).join(',\n') const openIndent = indentUnit.repeat(objectDepth) return `[\n${inner}\n${openIndent}]` } // 泛型对象:kind + entries if (Array.isArray(node.entries)) { if (objectDepth >= depthLimit) return `${colors.cyan}[${String(node.kind ?? 'Object')}]${colors.reset}` const entries = /** @type {Array<{ key: string; value: unknown }>} */ node.entries if (!entries.length) { const kind = String(node.kind ?? 'Object') return `${kind === 'object' || kind === 'Object' ? '' : kind + ' '}{}` } const isArrayLike = node.kind === 'array' const open = isArrayLike ? '[' : '{' const close = isArrayLike ? ']' : '}' const spaces = indentUnit.repeat(objectDepth + 1) const nextIndent = indentUnit.repeat(objectDepth) const lines = entries.map(entry => { const { key, value: val } = /** @type {{ key: string; value: unknown }} */ entry if (isArrayLike) return `${spaces}${formatNode(val, objectDepth + 1)}` let keyStr = key if (!/^[$A-Z_a-z][\w$]*$/.test(keyStr)) keyStr = `'${keyStr.replaceAll('\'', '\\\'').replaceAll('\n', '\\n')}'` return `${spaces}${keyStr}: ${formatNode(val, objectDepth + 1)}` }) const compactLines = entries.map(entry => { const { key, value: val } = /** @type {{ key: string; value: unknown }} */ entry if (isArrayLike) return formatNode(val, objectDepth + 1) let keyStr = key if (!/^[$A-Z_a-z][\w$]*$/.test(keyStr)) keyStr = `'${keyStr.replaceAll('\'', '\\\'').replaceAll('\n', '\\n')}'` return `${keyStr}: ${formatNode(val, objectDepth + 1)}` }) const compactInner = compactLines.join(', ') const compactPrefix = node.kind && node.kind !== 'object' && node.kind !== 'Object' ? `${node.kind} ` : '' if (!compactInner.includes('\n') && compactInner.length <= 80) return `${compactPrefix}${open} ${compactInner} ${close}` const prefix = node.kind && node.kind !== 'object' && node.kind !== 'Object' ? `${node.kind} ` : '' return `${prefix}${open}\n${lines.join(',\n')}\n${nextIndent}${close}` } return JSON.stringify(node) } /** * @param {unknown} snapshotNode - 快照树上的节点(或兜底的非对象原语)。 * @param {number} objectDepth - 对象深度。 * @returns {string} 与 `util.inspect` 一致的 `&lt;ref *N&gt;` 前缀 + 正文。 */ const formatNode = (snapshotNode, objectDepth) => { const raw = formatNodeImpl(snapshotNode, objectDepth) if (snapshotNode !== null && typeof snapshotNode === 'object' && typeof /** @type {{ inspectRefId?: number }} */ snapshotNode.inspectRefId === 'number') { const inspectRefIndex = /** @type {{ inspectRefId: number }} */ snapshotNode.inspectRefId const refPrefix = colorize ? `${colors.cyan}<ref *${inspectRefIndex}> ${colors.reset}` : `<ref *${inspectRefIndex}> ` return refPrefix + raw } return raw } return formatNode(snap, 0) }