Skip to content

Commit

Permalink
util: inspect (user defined) prototype properties
Browse files Browse the repository at this point in the history
This is only active if the `showHidden` option is truthy.

The implementation is a trade-off between accuracy and performance.
This will miss properties such as properties added to built-in data
types.

The goal is mainly to visualize prototype getters and setters such as:

class Foo {
  ownProperty = true
  get bar() {
    return 'Hello world!'
  }
}

const a = new Foo()

The `bar` property is a non-enumerable property on the prototype while
`ownProperty` will be set directly on the created instance.

The output is similar to the one of Chromium when inspecting objects
closer. The output from Firefox is difficult to compare, since it's
always a structured interactive output and was therefore not taken
into account.

Backport-PR-URL: #31431
PR-URL: #30768
Fixes: #30183
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Michaël Zasso <targos@protonmail.com>
  • Loading branch information
BridgeAR authored and BethGriggs committed Feb 6, 2020
1 parent 0376e1c commit 7686865
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 22 deletions.
7 changes: 6 additions & 1 deletion doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,10 @@ stream.write('With ES6');
<!-- YAML
added: v0.3.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/30768
description: User defined prototype properties are inspected in case
`showHidden` is `true`.
- version: v12.0.0
pr-url: https://github.com/nodejs/node/pull/27109
description: The `compact` options default is changed to `3` and the
Expand Down Expand Up @@ -458,7 +462,8 @@ changes:
* `options` {Object}
* `showHidden` {boolean} If `true`, `object`'s non-enumerable symbols and
properties are included in the formatted result. [`WeakMap`][] and
[`WeakSet`][] entries are also included. **Default:** `false`.
[`WeakSet`][] entries are also included as well as user defined prototype
properties (excluding method properties). **Default:** `false`.
* `depth` {number} Specifies the number of times to recurse while formatting
`object`. This is useful for inspecting large objects. To recurse up to
the maximum call stack size pass `Infinity` or `null`.
Expand Down
116 changes: 100 additions & 16 deletions lib/internal/util/inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,14 +450,20 @@ function getEmptyFormatArray() {
return [];
}

function getConstructorName(obj, ctx, recurseTimes) {
function getConstructorName(obj, ctx, recurseTimes, protoProps) {
let firstProto;
const tmp = obj;
while (obj) {
const descriptor = ObjectGetOwnPropertyDescriptor(obj, 'constructor');
if (descriptor !== undefined &&
typeof descriptor.value === 'function' &&
descriptor.value.name !== '') {
if (protoProps !== undefined &&
!builtInObjects.has(descriptor.value.name)) {
const isProto = firstProto !== undefined;
addPrototypeProperties(
ctx, tmp, obj, recurseTimes, isProto, protoProps);
}
return descriptor.value.name;
}

Expand All @@ -477,7 +483,8 @@ function getConstructorName(obj, ctx, recurseTimes) {
return `${res} <Complex prototype>`;
}

const protoConstr = getConstructorName(firstProto, ctx, recurseTimes + 1);
const protoConstr = getConstructorName(
firstProto, ctx, recurseTimes + 1, protoProps);

if (protoConstr === null) {
return `${res} <${inspect(firstProto, {
Expand All @@ -490,6 +497,68 @@ function getConstructorName(obj, ctx, recurseTimes) {
return `${res} <${protoConstr}>`;
}

// This function has the side effect of adding prototype properties to the
// `output` argument (which is an array). This is intended to highlight user
// defined prototype properties.
function addPrototypeProperties(ctx, main, obj, recurseTimes, isProto, output) {
let depth = 0;
let keys;
let keySet;
do {
if (!isProto) {
obj = ObjectGetPrototypeOf(obj);
// Stop as soon as a null prototype is encountered.
if (obj === null) {
return;
}
// Stop as soon as a built-in object type is detected.
const descriptor = ObjectGetOwnPropertyDescriptor(obj, 'constructor');
if (descriptor !== undefined &&
typeof descriptor.value === 'function' &&
builtInObjects.has(descriptor.value.name)) {
return;
}
} else {
isProto = false;
}

if (depth === 0) {
keySet = new Set();
} else {
keys.forEach((key) => keySet.add(key));
}
// Get all own property names and symbols.
keys = ObjectGetOwnPropertyNames(obj);
const symbols = ObjectGetOwnPropertySymbols(obj);
if (symbols.length !== 0) {
keys.push(...symbols);
}
for (const key of keys) {
// Ignore the `constructor` property and keys that exist on layers above.
if (key === 'constructor' ||
ObjectPrototypeHasOwnProperty(main, key) ||
(depth !== 0 && keySet.has(key))) {
continue;
}
const desc = ObjectGetOwnPropertyDescriptor(obj, key);
if (typeof desc.value === 'function') {
continue;
}
const value = formatProperty(
ctx, obj, recurseTimes, key, kObjectType, desc);
if (ctx.colors) {
// Faint!
output.push(`\u001b[2m${value}\u001b[22m`);
} else {
output.push(value);
}
}
// Limit the inspection to up to three prototype layers. Using `recurseTimes`
// is not a good choice here, because it's as if the properties are declared
// on the current object from the users perspective.
} while (++depth !== 3);
}

function getPrefix(constructor, tag, fallback) {
if (constructor === null) {
if (tag !== '') {
Expand Down Expand Up @@ -693,8 +762,17 @@ function formatValue(ctx, value, recurseTimes, typedArray) {

function formatRaw(ctx, value, recurseTimes, typedArray) {
let keys;
let protoProps;
if (ctx.showHidden && (recurseTimes <= ctx.depth || ctx.depth === null)) {
protoProps = [];
}

const constructor = getConstructorName(value, ctx, recurseTimes, protoProps);
// Reset the variable to check for this later on.
if (protoProps !== undefined && protoProps.length === 0) {
protoProps = undefined;
}

const constructor = getConstructorName(value, ctx, recurseTimes);
let tag = value[SymbolToStringTag];
// Only list the tag in case it's non-enumerable / not an own property.
// Otherwise we'd print this twice.
Expand Down Expand Up @@ -724,21 +802,21 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
// Only set the constructor for non ordinary ("Array [...]") arrays.
const prefix = getPrefix(constructor, tag, 'Array');
braces = [`${prefix === 'Array ' ? '' : prefix}[`, ']'];
if (value.length === 0 && keys.length === 0)
if (value.length === 0 && keys.length === 0 && protoProps === undefined)
return `${braces[0]}]`;
extrasType = kArrayExtrasType;
formatter = formatArray;
} else if (isSet(value)) {
keys = getKeys(value, ctx.showHidden);
const prefix = getPrefix(constructor, tag, 'Set');
if (value.size === 0 && keys.length === 0)
if (value.size === 0 && keys.length === 0 && protoProps === undefined)
return `${prefix}{}`;
braces = [`${prefix}{`, '}'];
formatter = formatSet;
} else if (isMap(value)) {
keys = getKeys(value, ctx.showHidden);
const prefix = getPrefix(constructor, tag, 'Map');
if (value.size === 0 && keys.length === 0)
if (value.size === 0 && keys.length === 0 && protoProps === undefined)
return `${prefix}{}`;
braces = [`${prefix}{`, '}'];
formatter = formatMap;
Expand Down Expand Up @@ -773,12 +851,12 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
} else if (tag !== '') {
braces[0] = `${getPrefix(constructor, tag, 'Object')}{`;
}
if (keys.length === 0) {
if (keys.length === 0 && protoProps === undefined) {
return `${braces[0]}}`;
}
} else if (typeof value === 'function') {
base = getFunctionBase(value, constructor, tag);
if (keys.length === 0)
if (keys.length === 0 && protoProps === undefined)
return ctx.stylize(base, 'special');
} else if (isRegExp(value)) {
// Make RegExps say that they are RegExps
Expand All @@ -788,8 +866,10 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
const prefix = getPrefix(constructor, tag, 'RegExp');
if (prefix !== 'RegExp ')
base = `${prefix}${base}`;
if (keys.length === 0 || (recurseTimes > ctx.depth && ctx.depth !== null))
if ((keys.length === 0 && protoProps === undefined) ||
(recurseTimes > ctx.depth && ctx.depth !== null)) {
return ctx.stylize(base, 'regexp');
}
} else if (isDate(value)) {
// Make dates with properties first say the date
base = NumberIsNaN(DatePrototypeGetTime(value)) ?
Expand All @@ -798,12 +878,12 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
const prefix = getPrefix(constructor, tag, 'Date');
if (prefix !== 'Date ')
base = `${prefix}${base}`;
if (keys.length === 0) {
if (keys.length === 0 && protoProps === undefined) {
return ctx.stylize(base, 'date');
}
} else if (isError(value)) {
base = formatError(value, constructor, tag, ctx);
if (keys.length === 0)
if (keys.length === 0 && protoProps === undefined)
return base;
} else if (isAnyArrayBuffer(value)) {
// Fast path for ArrayBuffer and SharedArrayBuffer.
Expand All @@ -814,7 +894,7 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
const prefix = getPrefix(constructor, tag, arrayType);
if (typedArray === undefined) {
formatter = formatArrayBuffer;
} else if (keys.length === 0) {
} else if (keys.length === 0 && protoProps === undefined) {
return prefix +
`{ byteLength: ${formatNumber(ctx.stylize, value.byteLength)} }`;
}
Expand All @@ -838,7 +918,7 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
formatter = formatNamespaceObject;
} else if (isBoxedPrimitive(value)) {
base = getBoxedBase(value, ctx, keys, constructor, tag);
if (keys.length === 0) {
if (keys.length === 0 && protoProps === undefined) {
return base;
}
} else {
Expand All @@ -858,7 +938,7 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
formatter = formatIterator;
// Handle other regular objects again.
} else {
if (keys.length === 0) {
if (keys.length === 0 && protoProps === undefined) {
if (isExternal(value))
return ctx.stylize('[External]', 'special');
return `${getCtxStyle(value, constructor, tag)}{}`;
Expand Down Expand Up @@ -886,6 +966,9 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
output.push(
formatProperty(ctx, value, recurseTimes, keys[i], extrasType));
}
if (protoProps !== undefined) {
output.push(...protoProps);
}
} catch (err) {
const constructorName = getCtxStyle(value, constructor, tag).slice(0, -1);
return handleMaxCallStackSize(ctx, err, constructorName, indentationLvl);
Expand Down Expand Up @@ -1349,6 +1432,7 @@ function formatTypedArray(ctx, value, recurseTimes) {
}
if (ctx.showHidden) {
// .buffer goes last, it's not a primitive like the others.
// All besides `BYTES_PER_ELEMENT` are actually getters.
ctx.indentationLvl += 2;
for (const key of [
'BYTES_PER_ELEMENT',
Expand Down Expand Up @@ -1497,10 +1581,10 @@ function formatPromise(ctx, value, recurseTimes) {
return output;
}

function formatProperty(ctx, value, recurseTimes, key, type) {
function formatProperty(ctx, value, recurseTimes, key, type, desc) {
let name, str;
let extra = ' ';
const desc = ObjectGetOwnPropertyDescriptor(value, key) ||
desc = desc || ObjectGetOwnPropertyDescriptor(value, key) ||
{ value: value[key], enumerable: true };
if (desc.value !== undefined) {
const diff = (type !== kObjectType || ctx.compact !== true) ? 2 : 3;
Expand Down
80 changes: 79 additions & 1 deletion test/parallel/test-util-inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,24 @@ assert.strictEqual(
{
class CustomArray extends Array {}
CustomArray.prototype[5] = 'foo';
CustomArray.prototype[49] = 'bar';
CustomArray.prototype.foo = true;
const arr = new CustomArray(50);
assert.strictEqual(util.inspect(arr), 'CustomArray [ <50 empty items> ]');
arr[49] = 'I win';
assert.strictEqual(
util.inspect(arr),
"CustomArray [ <49 empty items>, 'I win' ]"
);
assert.strictEqual(
util.inspect(arr, { showHidden: true }),
'CustomArray [\n' +
' <49 empty items>,\n' +
" 'I win',\n" +
' [length]: 50,\n' +
" '5': 'foo',\n" +
' foo: true\n' +
']'
);
}

// Array with extra properties.
Expand Down Expand Up @@ -2585,3 +2601,65 @@ assert.strictEqual(
throw err;
}
}

// Inspect prototype properties.
{
class Foo extends Map {
prop = false;
prop2 = true;
get abc() {
return true;
}
get def() {
return false;
}
set def(v) {}
get xyz() {
return 'Should be ignored';
}
func(a) {}
[util.inspect.custom]() {
return this;
}
}

class Bar extends Foo {
abc = true;
prop = true;
get xyz() {
return 'YES!';
}
[util.inspect.custom]() {
return this;
}
}

const bar = new Bar();

assert.strictEqual(
inspect(bar),
'Bar [Map] { prop: true, prop2: true, abc: true }'
);
assert.strictEqual(
inspect(bar, { showHidden: true, getters: true, colors: false }),
'Bar [Map] {\n' +
' [size]: 0,\n' +
' prop: true,\n' +
' prop2: true,\n' +
' abc: true,\n' +
" [xyz]: [Getter: 'YES!'],\n" +
' [def]: [Getter/Setter: false]\n' +
'}'
);
assert.strictEqual(
inspect(bar, { showHidden: true, getters: false, colors: true }),
'Bar [Map] {\n' +
' [size]: \x1B[33m0\x1B[39m,\n' +
' prop: \x1B[33mtrue\x1B[39m,\n' +
' prop2: \x1B[33mtrue\x1B[39m,\n' +
' abc: \x1B[33mtrue\x1B[39m,\n' +
' \x1B[2m[xyz]: \x1B[36m[Getter]\x1B[39m\x1B[22m,\n' +
' \x1B[2m[def]: \x1B[36m[Getter/Setter]\x1B[39m\x1B[22m\n' +
'}'
);
}
17 changes: 13 additions & 4 deletions test/parallel/test-whatwg-encoding-custom-textdecoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,19 @@ if (common.hasIntl) {
} else {
assert.strictEqual(
util.inspect(dec, { showHidden: true }),
"TextDecoder {\n encoding: 'utf-8',\n fatal: false,\n " +
'ignoreBOM: true,\n [Symbol(flags)]: 4,\n [Symbol(handle)]: ' +
"StringDecoder {\n encoding: 'utf8',\n " +
'[Symbol(kNativeDecoder)]: <Buffer 00 00 00 00 00 00 01>\n }\n}'
'TextDecoder {\n' +
" encoding: 'utf-8',\n" +
' fatal: false,\n' +
' ignoreBOM: true,\n' +
' [Symbol(flags)]: 4,\n' +
' [Symbol(handle)]: StringDecoder {\n' +
" encoding: 'utf8',\n" +
' [Symbol(kNativeDecoder)]: <Buffer 00 00 00 00 00 00 01>,\n' +
' lastChar: [Getter],\n' +
' lastNeed: [Getter],\n' +
' lastTotal: [Getter]\n' +
' }\n' +
'}'
);
}
}
Expand Down

0 comments on commit 7686865

Please sign in to comment.