node/lib/internal/errors/error_source.js
Chengzhong Wu f1b56d6200 lib: add source map support for assert messages
Map source lines in assert messages with cached source maps.

PR-URL: https://github.com/nodejs/node/pull/59751
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
2025-09-11 10:37:46 +00:00

166 lines
5.2 KiB
JavaScript

'use strict';
const {
FunctionPrototypeBind,
StringPrototypeSlice,
} = primordials;
const {
getErrorSourcePositions,
} = internalBinding('errors');
const {
getSourceMapsSupport,
findSourceMap,
getSourceLine,
} = require('internal/source_map/source_map_cache');
/**
* Get the source location of an error. If source map is enabled, resolve the source location
* based on the source map.
*
* The `error.stack` must not have been accessed. The resolution is based on the structured
* error stack data.
* @param {Error|object} error An error object, or an object being invoked with ErrorCaptureStackTrace
* @returns {{sourceLine: string, startColumn: number}|undefined}
*/
function getErrorSourceLocation(error) {
const pos = getErrorSourcePositions(error);
const {
sourceLine,
scriptResourceName,
lineNumber,
startColumn,
} = pos;
// Source map is not enabled. Return the source line directly.
if (!getSourceMapsSupport().enabled) {
return { sourceLine, startColumn };
}
const sm = findSourceMap(scriptResourceName);
if (sm === undefined) {
return;
}
const {
originalLine,
originalColumn,
originalSource,
} = sm.findEntry(lineNumber - 1, startColumn);
const originalSourceLine = getSourceLine(sm, originalSource, originalLine, originalColumn);
if (!originalSourceLine) {
return;
}
return {
sourceLine: originalSourceLine,
startColumn: originalColumn,
};
}
const memberAccessTokens = [ '.', '?.', '[', ']' ];
const memberNameTokens = [ 'name', 'string', 'num' ];
let tokenizer;
/**
* Get the first expression in a code string at the startColumn.
* @param {string} code source code line
* @param {number} startColumn which column the error is constructed
* @returns {string}
*/
function getFirstExpression(code, startColumn) {
// Lazy load acorn.
if (tokenizer === undefined) {
const Parser = require('internal/deps/acorn/acorn/dist/acorn').Parser;
tokenizer = FunctionPrototypeBind(Parser.tokenizer, Parser);
}
let lastToken;
let firstMemberAccessNameToken;
let terminatingCol;
let parenLvl = 0;
// Tokenize the line to locate the expression at the startColumn.
// The source line may be an incomplete JavaScript source, so do not parse the source line.
for (const token of tokenizer(code, { ecmaVersion: 'latest' })) {
// Peek before the startColumn.
if (token.start < startColumn) {
// There is a semicolon. This is a statement before the startColumn, so reset the memo.
if (token.type.label === ';') {
firstMemberAccessNameToken = null;
continue;
}
// Try to memo the member access expressions before the startColumn, so that the
// returned source code contains more info:
// assert.ok(value)
// ^ startColumn
// The member expression can also be like
// assert['ok'](value) or assert?.ok(value)
// ^ startColumn ^ startColumn
if (memberAccessTokens.includes(token.type.label) && lastToken?.type.label === 'name') {
// First member access name token must be a 'name'.
firstMemberAccessNameToken ??= lastToken;
} else if (!memberAccessTokens.includes(token.type.label) &&
!memberNameTokens.includes(token.type.label)) {
// Reset the memo if it is not a simple member access.
// For example: assert[(() => 'ok')()](value)
// ^ startColumn
firstMemberAccessNameToken = null;
}
lastToken = token;
continue;
}
// Now after the startColumn, this must be an expression.
if (token.type.label === '(') {
parenLvl++;
continue;
}
if (token.type.label === ')') {
parenLvl--;
if (parenLvl === 0) {
// A matched closing parenthesis found after the startColumn,
// terminate here. Include the token.
// (assert.ok(false), assert.ok(true))
// ^ startColumn
terminatingCol = token.start + 1;
break;
}
continue;
}
if (token.type.label === ';') {
// A semicolon found after the startColumn, terminate here.
// assert.ok(false); assert.ok(true));
// ^ startColumn
terminatingCol = token;
break;
}
// If no semicolon found after the startColumn. The string after the
// startColumn must be the expression.
// assert.ok(false)
// ^ startColumn
}
const start = firstMemberAccessNameToken?.start ?? startColumn;
return StringPrototypeSlice(code, start, terminatingCol);
}
/**
* Get the source expression of an error. If source map is enabled, resolve the source location
* based on the source map.
*
* The `error.stack` must not have been accessed, or the source location may be incorrect. The
* resolution is based on the structured error stack data.
* @param {Error|object} error An error object, or an object being invoked with ErrorCaptureStackTrace
* @returns {string|undefined}
*/
function getErrorSourceExpression(error) {
const loc = getErrorSourceLocation(error);
if (loc === undefined) {
return;
}
const { sourceLine, startColumn } = loc;
return getFirstExpression(sourceLine, startColumn);
}
module.exports = {
getErrorSourceLocation,
getErrorSourceExpression,
};