Skip to content

Commit 8c97ffb

Browse files
BridgeARtargos
authored andcommitted
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 <[email protected]> Reviewed-By: Ben Coe <[email protected]> Reviewed-By: James M Snell <[email protected]>
1 parent 2a0862c commit 8c97ffb

File tree

5 files changed

+221
-92
lines changed

5 files changed

+221
-92
lines changed

lib/assert.js

+146-78
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const { NativeModule } = require('internal/bootstrap/loaders');
3434

3535
let isDeepEqual;
3636
let isDeepStrictEqual;
37+
let parseExpressionAt;
38+
let findNodeAround;
39+
let columnOffset = 0;
40+
let decoder;
3741

3842
function lazyLoadComparison() {
3943
const comparison = require('internal/util/comparisons');
@@ -113,52 +117,141 @@ function fail(actual, expected, message, operator, stackStartFn) {
113117
assert.fail = fail;
114118

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

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

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

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

164257
if (errorCache.has(identifier)) {
@@ -171,57 +264,49 @@ function getErrMessage(call) {
171264
return;
172265
}
173266

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

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

test/fixtures/assert-first-line.js

+2
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

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)