Skip to content

Commit 0282eb8

Browse files
committed
util: add maxObjectProperties option to inspect
Add a maxObjectProperties option to util.inspect that caps the number of named properties included in formatted output. The limit applies independently from maxArrayLength and defaults to Infinity, preserving existing behavior. Own string and symbol properties are included before user-defined prototype properties. Array, Map, Set, and WeakSet entries governed by maxArrayLength, as well as built-in metadata entries such as [byteLength], [buffer], and [BYTES_PER_ELEMENT], do not consume the property budget. The limit is applied before sorted, and omitted properties are not evaluated (including getters and [util.inspect.custom]). Prototype property collection is deferred so truncated properties are discarded before their values are formatted. Assertion errors pass maxObjectProperties: Infinity explicitly so changes to inspect.defaultOptions do not affect diff output.
1 parent 69bf457 commit 0282eb8

8 files changed

Lines changed: 546 additions & 33 deletions

File tree

benchmark/util/inspect-object.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const util = require('util');
5+
6+
const bench = common.createBenchmark(main, {
7+
n: [1e3],
8+
len: [1e2, 1e4],
9+
maxObjectProperties: [0, 10, 100, Infinity],
10+
showHidden: [0, 1],
11+
});
12+
13+
function main({ n, len, maxObjectProperties, showHidden }) {
14+
const prototype = {};
15+
const object = { __proto__: prototype };
16+
for (let i = 0; i < len; i++) {
17+
object[`property${i}`] = { value: i };
18+
prototype[`prototypeProperty${i}`] = { value: i };
19+
}
20+
21+
const options = {
22+
maxObjectProperties,
23+
showHidden: showHidden === 1,
24+
};
25+
26+
bench.start();
27+
for (let i = 0; i < n; i++) {
28+
util.inspect(object, options);
29+
}
30+
bench.end(n);
31+
}

doc/api/util.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,9 @@ stream.write('With ES6');
911911
<!-- YAML
912912
added: v0.3.0
913913
changes:
914+
- version: REPLACEME
915+
pr-url: https://github.com/nodejs/node/pull/65242
916+
description: The `maxObjectProperties` option is supported now.
914917
- version:
915918
- v25.0.0
916919
pr-url: https://github.com/nodejs/node/pull/59710
@@ -1023,6 +1026,14 @@ changes:
10231026
{TypedArray}, {Map}, {WeakMap}, and {WeakSet} elements to include when formatting.
10241027
Set to `null` or `Infinity` to show all elements. Set to `0` or
10251028
negative to show no elements. **Default:** `100`.
1029+
* `maxObjectProperties` {integer} Specifies the maximum number of named
1030+
properties per inspected value. Own string and symbol properties are
1031+
included before user-defined prototype properties. Content governed by
1032+
`maxArrayLength` and built-in metadata entries such as `[byteLength]`,
1033+
`[buffer]`, and `[BYTES_PER_ELEMENT]` do not count. The limit is applied
1034+
before `sorted`.
1035+
Set to `null` or `Infinity` to show all properties. Set to `0` or negative
1036+
to show no properties. **Default:** `Infinity`.
10261037
* `maxStringLength` {integer} Specifies the maximum number of characters to
10271038
include when formatting. Set to `null` or `Infinity` to show all elements.
10281039
Set to `0` or negative to show no characters. **Default:** `10000`.

lib/internal/assert/assertion_error.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ function inspectValue(val) {
7777
customInspect: false,
7878
depth: 1000,
7979
maxArrayLength: Infinity,
80+
maxObjectProperties: Infinity,
8081
// Assert compares only enumerable properties (with a few exceptions).
8182
showHidden: false,
8283
// Assert does not detect proxies currently.

lib/internal/util/inspect.js

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ const {
1414
ArrayPrototypeIndexOf,
1515
ArrayPrototypeJoin,
1616
ArrayPrototypeMap,
17-
ArrayPrototypePop,
1817
ArrayPrototypePush,
1918
ArrayPrototypePushApply,
2019
ArrayPrototypeSlice,
@@ -227,6 +226,7 @@ const inspectDefaultOptions = ObjectSeal({
227226
customInspect: true,
228227
showProxy: false,
229228
maxArrayLength: 100,
229+
maxObjectProperties: Infinity,
230230
maxStringLength: 10000,
231231
breakLength: 80,
232232
compact: 3,
@@ -305,6 +305,7 @@ function getUserOptions(ctx, isCrossContext) {
305305
customInspect: ctx.customInspect,
306306
showProxy: ctx.showProxy,
307307
maxArrayLength: ctx.maxArrayLength,
308+
maxObjectProperties: ctx.maxObjectProperties,
308309
maxStringLength: ctx.maxStringLength,
309310
breakLength: ctx.breakLength,
310311
compact: ctx.compact,
@@ -365,6 +366,7 @@ function inspect(value, opts) {
365366
customInspect: inspectDefaultOptions.customInspect,
366367
showProxy: inspectDefaultOptions.showProxy,
367368
maxArrayLength: inspectDefaultOptions.maxArrayLength,
369+
maxObjectProperties: inspectDefaultOptions.maxObjectProperties,
368370
maxStringLength: inspectDefaultOptions.maxStringLength,
369371
breakLength: inspectDefaultOptions.breakLength,
370372
compact: inspectDefaultOptions.compact,
@@ -405,6 +407,7 @@ function inspect(value, opts) {
405407
}
406408
if (ctx.colors) ctx.stylize = stylizeWithColor;
407409
if (ctx.maxArrayLength === null) ctx.maxArrayLength = Infinity;
410+
if (ctx.maxObjectProperties === null) ctx.maxObjectProperties = Infinity;
408411
if (ctx.maxStringLength === null) ctx.maxStringLength = Infinity;
409412
return formatValue(ctx, value, 0);
410413
}
@@ -914,8 +917,7 @@ function getConstructorName(obj, ctx, recurseTimes, protoProps) {
914917
const { name, constructor } = wellKnownPrototypeNameAndConstructor;
915918
if (FunctionPrototypeSymbolHasInstance(constructor, tmp)) {
916919
if (protoProps !== undefined && firstProto !== obj) {
917-
addPrototypeProperties(
918-
ctx, tmp, firstProto || tmp, recurseTimes, protoProps);
920+
addPrototypeProperties(tmp, firstProto || tmp, protoProps);
919921
}
920922
return name;
921923
}
@@ -928,8 +930,7 @@ function getConstructorName(obj, ctx, recurseTimes, protoProps) {
928930
if (protoProps !== undefined &&
929931
(firstProto !== obj ||
930932
!builtInObjects.has(descriptor.value.name))) {
931-
addPrototypeProperties(
932-
ctx, tmp, firstProto || tmp, recurseTimes, protoProps);
933+
addPrototypeProperties(tmp, firstProto || tmp, protoProps);
933934
}
934935
return String(descriptor.value.name);
935936
}
@@ -964,10 +965,9 @@ function getConstructorName(obj, ctx, recurseTimes, protoProps) {
964965
return `${res} <${protoConstr}>`;
965966
}
966967

967-
// This function has the side effect of adding prototype properties to the
968-
// `output` argument (which is an array). This is intended to highlight user
969-
// defined prototype properties.
970-
function addPrototypeProperties(ctx, main, obj, recurseTimes, output) {
968+
// Collect user defined prototype properties. They are formatted later so a
969+
// property limit can discard them without inspecting their values.
970+
function addPrototypeProperties(main, obj, output) {
971971
let depth = 0;
972972
let keys;
973973
let keySet;
@@ -994,7 +994,6 @@ function addPrototypeProperties(ctx, main, obj, recurseTimes, output) {
994994
}
995995
// Get all own property names and symbols.
996996
keys = ReflectOwnKeys(obj);
997-
ArrayPrototypePush(ctx.seen, main);
998997
for (const key of keys) {
999998
// Ignore the `constructor` property and keys that exist on layers above.
1000999
if (key === 'constructor' ||
@@ -1006,22 +1005,41 @@ function addPrototypeProperties(ctx, main, obj, recurseTimes, output) {
10061005
if (typeof desc.value === 'function') {
10071006
continue;
10081007
}
1009-
const value = formatProperty(
1010-
ctx, obj, recurseTimes, key, kObjectType, desc, main);
1011-
if (ctx.colors) {
1012-
// Faint!
1013-
ArrayPrototypePush(output, `\u001b[2m${value}\u001b[22m`);
1014-
} else {
1015-
ArrayPrototypePush(output, value);
1016-
}
1008+
ArrayPrototypePush(output, [obj, key, desc]);
10171009
}
1018-
ArrayPrototypePop(ctx.seen);
10191010
// Limit the inspection to up to three prototype layers. Using `recurseTimes`
10201011
// is not a good choice here, because it's as if the properties are declared
10211012
// on the current object from the users perspective.
10221013
} while (++depth !== 3);
10231014
}
10241015

1016+
function truncateProperties(properties, limit) {
1017+
// A NaN limit leaves `properties` untouched.
1018+
if (!(properties.length > limit)) {
1019+
return 0;
1020+
}
1021+
const omitted = properties.length - limit;
1022+
properties.length = limit;
1023+
return omitted;
1024+
}
1025+
1026+
// Discards properties beyond `ctx.maxObjectProperties`, retaining own
1027+
// properties before prototype properties. Property candidates have already
1028+
// been collected, so this limits formatting rather than enumeration. Returns
1029+
// the number omitted.
1030+
function limitProperties(ctx, keys, protoProps) {
1031+
if (ctx.maxObjectProperties === Infinity) {
1032+
return 0;
1033+
}
1034+
// Assigning to `array.length` requires an integral value.
1035+
const budget = MathTrunc(MathMax(0, ctx.maxObjectProperties));
1036+
let omitted = truncateProperties(keys, budget);
1037+
if (protoProps !== undefined) {
1038+
omitted += truncateProperties(protoProps, budget - keys.length);
1039+
}
1040+
return omitted;
1041+
}
1042+
10251043
/** @type {(constructor: string, tag: string, fallback: string, size?: string) => string} */
10261044
function getPrefix(constructor, tag, fallback, size = '') {
10271045
if (constructor === null) {
@@ -1422,9 +1440,26 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
14221440
constructorName = `[${constructorName}]`;
14231441
return ctx.stylize(constructorName, 'special');
14241442
}
1443+
1444+
// This must run after type-specific key normalization and empty-value fast
1445+
// paths. It must not move into getKeys().
1446+
const remainingProperties = limitProperties(ctx, keys, protoProps);
1447+
1448+
const protoRecurseTimes = recurseTimes;
14251449
recurseTimes += 1;
14261450

14271451
ctx.seen.push(value);
1452+
let formattedProtoProps;
1453+
if (protoProps !== undefined) {
1454+
formattedProtoProps = new Array(protoProps.length);
1455+
for (i = 0; i < protoProps.length; i++) {
1456+
const { 0: obj, 1: key, 2: desc } = protoProps[i];
1457+
const formatted = formatProperty(
1458+
ctx, obj, protoRecurseTimes, key, kObjectType, desc, value);
1459+
formattedProtoProps[i] =
1460+
ctx.colors ? `\u001b[2m${formatted}\u001b[22m` : formatted;
1461+
}
1462+
}
14281463
ctx.currentDepth = recurseTimes;
14291464
let output;
14301465
const indentationLvl = ctx.indentationLvl;
@@ -1448,8 +1483,8 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
14481483
formatProperty(ctx, value, recurseTimes, keys[i], extrasType),
14491484
);
14501485
}
1451-
if (protoProps !== undefined) {
1452-
ArrayPrototypePushApply(output, protoProps);
1486+
if (formattedProtoProps !== undefined) {
1487+
ArrayPrototypePushApply(output, formattedProtoProps);
14531488
}
14541489
} catch (err) {
14551490
if (!isStackOverflowError(err)) throw err;
@@ -1480,9 +1515,16 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
14801515
ReflectApply(ArrayPrototypeSplice, null, sorted);
14811516
}
14821517
}
1518+
if (remainingProperties > 0) {
1519+
ArrayPrototypePush(
1520+
output,
1521+
remainingText(remainingProperties, 'property', 'properties'),
1522+
);
1523+
}
14831524

14841525
const res = reduceToSingleString(
1485-
ctx, output, base, braces, extrasType, recurseTimes, value);
1526+
ctx, output, base, braces, extrasType, recurseTimes, value,
1527+
remainingProperties > 0 ? 1 : 0);
14861528
const budget = ctx.budget[ctx.indentationLvl] || 0;
14871529
const newLength = budget + res.length;
14881530
ctx.budget[ctx.indentationLvl] = newLength;
@@ -2048,12 +2090,15 @@ function formatError(err, constructor, tag, ctx, keys) {
20482090
return stack;
20492091
}
20502092

2051-
function groupArrayElements(ctx, output, value) {
2093+
// `trailingEntries` counts entries at the end of `output` that describe omitted
2094+
// content rather than real entries. They must not take part in grouping.
2095+
function groupArrayElements(ctx, output, value, trailingEntries) {
20522096
let totalLength = 0;
20532097
let maxLength = 0;
20542098
let i = 0;
2055-
let outputLength = output.length;
2056-
if (ctx.maxArrayLength < output.length) {
2099+
const outputLengthWithoutTrailing = output.length - trailingEntries;
2100+
let outputLength = outputLengthWithoutTrailing;
2101+
if (ctx.maxArrayLength < outputLengthWithoutTrailing) {
20572102
// This makes sure the "... n more items" part is not taken into account.
20582103
outputLength--;
20592104
}
@@ -2080,7 +2125,8 @@ function groupArrayElements(ctx, output, value) {
20802125
(totalLength / actualMax > 5 || maxLength <= 6)) {
20812126

20822127
const approxCharHeights = 2.5;
2083-
const averageBias = MathSqrt(actualMax - totalLength / output.length);
2128+
const averageBias =
2129+
MathSqrt(actualMax - totalLength / outputLengthWithoutTrailing);
20842130
const biasedMax = MathMax(actualMax - 3 - averageBias, 1);
20852131
// Dynamically check how many columns seem possible.
20862132
const columns = MathMin(
@@ -2110,7 +2156,7 @@ function groupArrayElements(ctx, output, value) {
21102156
const maxLineLength = [];
21112157
for (let i = 0; i < columns; i++) {
21122158
let lineMaxLength = 0;
2113-
for (let j = i; j < output.length; j += columns) {
2159+
for (let j = i; j < outputLengthWithoutTrailing; j += columns) {
21142160
if (dataLen[j] > lineMaxLength)
21152161
lineMaxLength = dataLen[j];
21162162
}
@@ -2119,7 +2165,7 @@ function groupArrayElements(ctx, output, value) {
21192165
}
21202166
let order = StringPrototypePadStart;
21212167
if (value !== undefined) {
2122-
for (let i = 0; i < output.length; i++) {
2168+
for (let i = 0; i < outputLengthWithoutTrailing; i++) {
21232169
if (typeof value[i] !== 'number' && typeof value[i] !== 'bigint') {
21242170
order = StringPrototypePadEnd;
21252171
break;
@@ -2150,9 +2196,12 @@ function groupArrayElements(ctx, output, value) {
21502196
}
21512197
ArrayPrototypePush(tmp, str);
21522198
}
2153-
if (ctx.maxArrayLength < output.length) {
2199+
if (ctx.maxArrayLength < outputLengthWithoutTrailing) {
21542200
ArrayPrototypePush(tmp, output[outputLength]);
21552201
}
2202+
for (let i = outputLengthWithoutTrailing; i < output.length; i++) {
2203+
ArrayPrototypePush(tmp, output[i]);
2204+
}
21562205
output = tmp;
21572206
}
21582207
return output;
@@ -2192,7 +2241,8 @@ function addNumericSeparatorEnd(integerString) {
21922241
`${result}${StringPrototypeSlice(integerString, i)}`;
21932242
}
21942243

2195-
const remainingText = (remaining) => `... ${remaining} more item${remaining > 1 ? 's' : ''}`;
2244+
const remainingText = (remaining, singular = 'item', plural = 'items') =>
2245+
`... ${remaining} more ${remaining > 1 ? plural : singular}`;
21962246

21972247
function formatNumber(fn, number, numericSeparator) {
21982248
// Format -0 as '-0'. Checking `number === -0` won't distinguish 0 from -0.
@@ -2642,7 +2692,8 @@ function isBelowBreakLength(ctx, output, start, base) {
26422692
}
26432693

26442694
function reduceToSingleString(
2645-
ctx, output, base, braces, extrasType, recurseTimes, value) {
2695+
ctx, output, base, braces, extrasType, recurseTimes, value,
2696+
trailingEntries = 0) {
26462697
if (ctx.compact !== true) {
26472698
if (typeof ctx.compact === 'number' && ctx.compact >= 1) {
26482699
// Memorize the original output length. In case the output is grouped,
@@ -2651,7 +2702,7 @@ function reduceToSingleString(
26512702
// Group array elements together if the array contains at least six
26522703
// separate entries.
26532704
if (extrasType === kArrayExtrasType && entries > 6) {
2654-
output = groupArrayElements(ctx, output, value);
2705+
output = groupArrayElements(ctx, output, value, trailingEntries);
26552706
}
26562707
// `ctx.currentDepth` is set to the most inner depth of the currently
26572708
// inspected object part while `recurseTimes` is the actual current depth

test/parallel/test-assert-deep.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,6 +1170,26 @@ test('Check proxies', () => {
11701170
);
11711171
});
11721172

1173+
test('Assertion errors ignore the maxObjectProperties default', () => {
1174+
const original = util.inspect.defaultOptions.maxObjectProperties;
1175+
util.inspect.defaultOptions.maxObjectProperties = 1;
1176+
try {
1177+
assert.throws(
1178+
() => assert.deepStrictEqual({ a: 1, b: 2 }, { a: 1, b: 3 }),
1179+
{
1180+
message: `${defaultMsgStartFull}\n\n` +
1181+
' {\n' +
1182+
' a: 1,\n' +
1183+
'+ b: 2\n' +
1184+
'- b: 3\n' +
1185+
' }\n'
1186+
}
1187+
);
1188+
} finally {
1189+
util.inspect.defaultOptions.maxObjectProperties = original;
1190+
}
1191+
});
1192+
11731193
test('Strict equal with identical objects that are not identical ' +
11741194
'by reference and longer than 50 elements', () => {
11751195
// E.g., assert.deepStrictEqual({ a: Symbol() }, { a: Symbol() })

test/parallel/test-repl-inspect-defaults.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ child.on('exit', common.mustCall(() => {
1717
results,
1818
[
1919
'[ 42, 23 ]',
20+
'{ first: 1, second: 2 }',
21+
'1',
22+
'{ first: 1, ... 1 more property }',
2023
'1',
2124
'[ 42, ... 1 more item ]',
2225
'',
@@ -25,6 +28,9 @@ child.on('exit', common.mustCall(() => {
2528
}));
2629

2730
child.stdin.write('[ 42, 23 ]\n');
31+
child.stdin.write('({ first: 1, second: 2 })\n');
32+
child.stdin.write('util.inspect.replDefaults.maxObjectProperties = 1\n');
33+
child.stdin.write('({ first: 1, second: 2 })\n');
2834
child.stdin.write('util.inspect.replDefaults.maxArrayLength = 1\n');
2935
child.stdin.write('[ 42, 23 ]\n');
3036
child.stdin.end();

0 commit comments

Comments
 (0)