Skip to content

Commit 43ee4d6

Browse files
committed
assert: improve simple assert
1) If simple assert is called in the very first line of a file and it causes an error, it used to report the wrong code. The reason is that the column that is reported is faulty. This is fixed by subtracting the offset from now on in such cases. 2) The actual code read is now limited to the part that is actually required to visualize the call site. All other code in e.g. minified files will not cause a significant overhead anymore. 3) The number of allocations is now significantly lower than it used to be. The buffer is reused until the correct line in the code is found. In general the algorithm tries to safe operations where possible. 4) The indentation is now corrected depending on where the statement actually beginns. 5) It is now possible to handle `.call()` and `.apply()` properly. 6) The user defined function name will now always be used instead of only choosing either `assert.ok()` or `assert()`. PR-URL: #21626 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Ben Coe <bencoe@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 7eeb794 commit 43ee4d6

File tree

5 files changed

+221
-92
lines changed

5 files changed

+221
-92
lines changed

lib/assert.js

Lines changed: 146 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ const { NativeModule } = require('internal/bootstrap/loaders');
3535

3636
let isDeepEqual;
3737
let isDeepStrictEqual;
38+
let parseExpressionAt;
39+
let findNodeAround;
40+
let columnOffset = 0;
41+
let decoder;
3842

3943
function lazyLoadComparison() {
4044
const comparison = require('internal/util/comparisons');
@@ -114,52 +118,141 @@ function fail(actual, expected, message, operator, stackStartFn) {
114118
assert.fail = fail;
115119

116120
// The AssertionError is defined in internal/error.
117-
// new assert.AssertionError({ message: message,
118-
// actual: actual,
119-
// expected: expected });
120121
assert.AssertionError = AssertionError;
121122

122-
function getBuffer(fd, assertLine) {
123+
function findColumn(fd, column, code) {
124+
if (code.length > column + 100) {
125+
try {
126+
return parseCode(code, column);
127+
} catch {
128+
// End recursion in case no code could be parsed. The expression should
129+
// have been found after 2500 characters, so stop trying.
130+
if (code.length - column > 2500) {
131+
// eslint-disable-next-line no-throw-literal
132+
throw null;
133+
}
134+
}
135+
}
136+
// Read up to 2500 bytes more than necessary in columns. That way we address
137+
// multi byte characters and read enough data to parse the code.
138+
const bytesToRead = column - code.length + 2500;
139+
const buffer = Buffer.allocUnsafe(bytesToRead);
140+
const bytesRead = readSync(fd, buffer, 0, bytesToRead);
141+
code += decoder.write(buffer.slice(0, bytesRead));
142+
// EOF: fast path.
143+
if (bytesRead < bytesToRead) {
144+
return parseCode(code, column);
145+
}
146+
// Read potentially missing code.
147+
return findColumn(fd, column, code);
148+
}
149+
150+
function getCode(fd, line, column) {
151+
let bytesRead = 0;
152+
if (line === 0) {
153+
// Special handle line number one. This is more efficient and simplifies the
154+
// rest of the algorithm. Read more than the regular column number in bytes
155+
// to prevent multiple reads in case multi byte characters are used.
156+
return findColumn(fd, column, '');
157+
}
123158
let lines = 0;
124159
// Prevent blocking the event loop by limiting the maximum amount of
125160
// data that may be read.
126161
let maxReads = 64; // bytesPerRead * maxReads = 512 kb
127-
let bytesRead = 0;
128-
let startBuffer = 0; // Start reading from that char on
129162
const bytesPerRead = 8192;
130-
const buffers = [];
131-
do {
132-
const buffer = Buffer.allocUnsafe(bytesPerRead);
163+
// Use a single buffer up front that is reused until the call site is found.
164+
let buffer = Buffer.allocUnsafe(bytesPerRead);
165+
while (maxReads-- !== 0) {
166+
// Only allocate a new buffer in case the needed line is found. All data
167+
// before that can be discarded.
168+
buffer = lines < line ? buffer : Buffer.allocUnsafe(bytesPerRead);
133169
bytesRead = readSync(fd, buffer, 0, bytesPerRead);
170+
// Read the buffer until the required code line is found.
134171
for (var i = 0; i < bytesRead; i++) {
135-
if (buffer[i] === 10) {
136-
lines++;
137-
if (lines === assertLine) {
138-
startBuffer = i + 1;
139-
// Read up to 15 more lines to make sure all code gets matched
140-
} else if (lines === assertLine + 16) {
141-
buffers.push(buffer.slice(startBuffer, i));
142-
return buffers;
172+
if (buffer[i] === 10 && ++lines === line) {
173+
// If the end of file is reached, directly parse the code and return.
174+
if (bytesRead < bytesPerRead) {
175+
return parseCode(buffer.toString('utf8', i + 1, bytesRead), column);
143176
}
177+
// Check if the read code is sufficient or read more until the whole
178+
// expression is read. Make sure multi byte characters are preserved
179+
// properly by using the decoder.
180+
const code = decoder.write(buffer.slice(i + 1, bytesRead));
181+
return findColumn(fd, column, code);
144182
}
145183
}
146-
if (lines >= assertLine) {
147-
buffers.push(buffer.slice(startBuffer, bytesRead));
148-
// Reset the startBuffer in case we need more than one chunk
149-
startBuffer = 0;
184+
}
185+
}
186+
187+
function parseCode(code, offset) {
188+
// Lazy load acorn.
189+
if (parseExpressionAt === undefined) {
190+
({ parseExpressionAt } = require('internal/deps/acorn/dist/acorn'));
191+
({ findNodeAround } = require('internal/deps/acorn/dist/walk'));
192+
}
193+
let node;
194+
let start = 0;
195+
// Parse the read code until the correct expression is found.
196+
do {
197+
try {
198+
node = parseExpressionAt(code, start);
199+
start = node.end + 1 || start;
200+
// Find the CallExpression in the tree.
201+
node = findNodeAround(node, offset, 'CallExpression');
202+
} catch (err) {
203+
// Unexpected token error and the like.
204+
start += err.raisedAt || 1;
205+
if (start > offset) {
206+
// No matching expression found. This could happen if the assert
207+
// expression is bigger than the provided buffer.
208+
// eslint-disable-next-line no-throw-literal
209+
throw null;
210+
}
150211
}
151-
} while (--maxReads !== 0 && bytesRead !== 0);
152-
return buffers;
212+
} while (node === undefined || node.node.end < offset);
213+
214+
return [
215+
node.node.start,
216+
code.slice(node.node.start, node.node.end)
217+
.replace(escapeSequencesRegExp, escapeFn)
218+
];
153219
}
154220

155-
function getErrMessage(call) {
221+
function getErrMessage(message, fn) {
222+
const tmpLimit = Error.stackTraceLimit;
223+
// Make sure the limit is set to 1. Otherwise it could fail (<= 0) or it
224+
// does to much work.
225+
Error.stackTraceLimit = 1;
226+
// We only need the stack trace. To minimize the overhead use an object
227+
// instead of an error.
228+
const err = {};
229+
Error.captureStackTrace(err, fn);
230+
Error.stackTraceLimit = tmpLimit;
231+
232+
const tmpPrepare = Error.prepareStackTrace;
233+
Error.prepareStackTrace = (_, stack) => stack;
234+
const call = err.stack[0];
235+
Error.prepareStackTrace = tmpPrepare;
236+
156237
const filename = call.getFileName();
238+
157239
if (!filename) {
158-
return;
240+
return message;
159241
}
160242

161243
const line = call.getLineNumber() - 1;
162-
const column = call.getColumnNumber() - 1;
244+
let column = call.getColumnNumber() - 1;
245+
246+
// Line number one reports the wrong column due to being wrapped in a
247+
// function. Remove that offset to get the actual call.
248+
if (line === 0) {
249+
if (columnOffset === 0) {
250+
const { wrapper } = require('internal/modules/cjs/loader');
251+
columnOffset = wrapper[0].length;
252+
}
253+
column -= columnOffset;
254+
}
255+
163256
const identifier = `${filename}${line}${column}`;
164257

165258
if (errorCache.has(identifier)) {
@@ -172,57 +265,49 @@ function getErrMessage(call) {
172265
return;
173266
}
174267

175-
let fd, message;
268+
let fd;
176269
try {
270+
// Set the stack trace limit to zero. This makes sure unexpected token
271+
// errors are handled faster.
272+
Error.stackTraceLimit = 0;
273+
274+
if (decoder === undefined) {
275+
const { StringDecoder } = require('string_decoder');
276+
decoder = new StringDecoder('utf8');
277+
}
278+
177279
fd = openSync(filename, 'r', 0o666);
178-
const buffers = getBuffer(fd, line);
179-
const code = Buffer.concat(buffers).toString('utf8');
180-
// Lazy load acorn.
181-
const { parseExpressionAt } = require('internal/deps/acorn/dist/acorn');
182-
const nodes = parseExpressionAt(code, column);
183-
// Node type should be "CallExpression" and some times
184-
// "SequenceExpression".
185-
const node = nodes.type === 'CallExpression' ? nodes : nodes.expressions[0];
186-
const name = node.callee.name;
187-
// Calling `ok` with .apply or .call is uncommon but we use a simple
188-
// safeguard nevertheless.
189-
if (name !== 'apply' && name !== 'call') {
190-
// Only use `assert` and `assert.ok` to reference the "real API" and
191-
// not user defined function names.
192-
const ok = name === 'ok' ? '.ok' : '';
193-
const args = node.arguments;
194-
message = code
195-
.slice(args[0].start, args[args.length - 1].end)
196-
.replace(escapeSequencesRegExp, escapeFn);
280+
// Reset column and message.
281+
[column, message] = getCode(fd, line, column);
282+
// Flush unfinished multi byte characters.
283+
decoder.end();
284+
// Always normalize indentation, otherwise the message could look weird.
285+
if (message.indexOf('\n') !== -1) {
197286
if (EOL === '\r\n') {
198287
message = message.replace(/\r\n/g, '\n');
199288
}
200-
// Always normalize indentation, otherwise the message could look weird.
201-
if (message.indexOf('\n') !== -1) {
202-
const tmp = message.split('\n');
203-
message = tmp[0];
204-
for (var i = 1; i < tmp.length; i++) {
205-
let pos = 0;
206-
while (pos < column &&
207-
(tmp[i][pos] === ' ' || tmp[i][pos] === '\t')) {
208-
pos++;
209-
}
210-
message += `\n ${tmp[i].slice(pos)}`;
289+
const frames = message.split('\n');
290+
message = frames.shift();
291+
for (const frame of frames) {
292+
let pos = 0;
293+
while (pos < column && (frame[pos] === ' ' || frame[pos] === '\t')) {
294+
pos++;
211295
}
296+
message += `\n ${frame.slice(pos)}`;
212297
}
213-
message = 'The expression evaluated to a falsy value:' +
214-
`\n\n assert${ok}(${message})\n`;
215298
}
299+
message = `The expression evaluated to a falsy value:\n\n ${message}\n`;
216300
// Make sure to always set the cache! No matter if the message is
217301
// undefined or not
218302
errorCache.set(identifier, message);
219303

220304
return message;
221-
222305
} catch (e) {
223306
// Invalidate cache to prevent trying to read this part again.
224307
errorCache.set(identifier, undefined);
225308
} finally {
309+
// Reset limit.
310+
Error.stackTraceLimit = tmpLimit;
226311
if (fd !== undefined)
227312
closeSync(fd);
228313
}
@@ -236,25 +321,8 @@ function innerOk(fn, argLen, value, message) {
236321
generatedMessage = true;
237322
message = 'No value argument passed to `assert.ok()`';
238323
} else if (message == null) {
239-
// Use the call as error message if possible.
240-
// This does not work with e.g. the repl.
241-
// eslint-disable-next-line no-restricted-syntax
242-
const err = new Error();
243-
// Make sure the limit is set to 1. Otherwise it could fail (<= 0) or it
244-
// does to much work.
245-
const tmpLimit = Error.stackTraceLimit;
246-
Error.stackTraceLimit = 1;
247-
Error.captureStackTrace(err, fn);
248-
Error.stackTraceLimit = tmpLimit;
249-
250-
const tmpPrepare = Error.prepareStackTrace;
251-
Error.prepareStackTrace = (_, stack) => stack;
252-
const call = err.stack[0];
253-
Error.prepareStackTrace = tmpPrepare;
254-
255-
// Make sure it would be "null" in case that is used.
256-
message = getErrMessage(call) || message;
257324
generatedMessage = true;
325+
message = getErrMessage(message, fn);
258326
} else if (message instanceof Error) {
259327
throw message;
260328
}

test/fixtures/assert-first-line.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
'use strict'; const ässört = require('assert'); ässört(true); ässört.ok(''); ässört(null);
2+
// aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(false);

test/fixtures/assert-long-line.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)