From 8bb30926fdc32569b76d6bf19940494205518f3d Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Fri, 22 Feb 2019 23:31:42 +0100 Subject: [PATCH] util: group array elements together When using `util.inspect()` with `compact` mode set to a number, all array entries exceeding 6 are going to be grouped together into logical parts. PR-URL: https://github.com/nodejs/node/pull/26269 Reviewed-By: James M Snell Reviewed-By: Anna Henningsen --- doc/api/util.md | 6 +- lib/internal/util/inspect.js | 178 +++++++++++++++++++++----- test/parallel/test-util-inspect.js | 193 +++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 36 deletions(-) diff --git a/doc/api/util.md b/doc/api/util.md index 2826e0f1a633e5..ae71ad1ecf0447 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -468,9 +468,9 @@ changes: to be displayed on a new line. It will also add new lines to text that is longer than `breakLength`. If set to a number, the most `n` inner elements are united on a single line as long as all properties fit into - `breakLength`. Note that no text will be reduced below 16 characters, no - matter the `breakLength` size. For more information, see the example below. - **Default:** `true`. + `breakLength`. Short array elements are also grouped together. Note that no + text will be reduced below 16 characters, no matter the `breakLength` size. + For more information, see the example below. **Default:** `true`. * `sorted` {boolean|Function} If set to `true` or a function, all properties of an object, and `Set` and `Map` entries are sorted in the resulting string. If set to `true` the [default sort][] is used. If set to a function, diff --git a/lib/internal/util/inspect.js b/lib/internal/util/inspect.js index 277cc9aa429ad6..c1fc1944f136b2 100644 --- a/lib/internal/util/inspect.js +++ b/lib/internal/util/inspect.js @@ -794,8 +794,35 @@ function formatRaw(ctx, value, recurseTimes, typedArray) { } } - const combine = typeof ctx.compact === 'number' && - ctx.currentDepth - recurseTimes < ctx.compact; + let combine = false; + if (typeof ctx.compact === 'number') { + // Memorize the original output length. In case the the output is grouped, + // prevent lining up the entries on a single line. + const entries = output.length; + // Group array elements together if the array contains at least six separate + // entries. + if (extrasType === kArrayExtrasType && output.length > 6) { + output = groupArrayElements(ctx, output); + } + // `ctx.currentDepth` is set to the most inner depth of the currently + // inspected object part while `recurseTimes` is the actual current depth + // that is inspected. + // + // Example: + // + // const a = { first: [ 1, 2, 3 ], second: { inner: [ 1, 2, 3 ] } } + // + // The deepest depth of `a` is 2 (a.second.inner) and `a.first` has a max + // depth of 1. + // + // Consolidate all entries of the local most inner depth up to + // `ctx.compact`, as long as the properties are smaller than + // `ctx.breakLength`. + if (ctx.currentDepth - recurseTimes < ctx.compact && + entries === output.length) { + combine = true; + } + } const res = reduceToSingleString(ctx, output, base, braces, combine); const budget = ctx.budget[ctx.indentationLvl] || 0; @@ -814,6 +841,83 @@ function formatRaw(ctx, value, recurseTimes, typedArray) { return res; } +function groupArrayElements(ctx, output) { + let totalLength = 0; + let maxLength = 0; + let i = 0; + const dataLen = new Array(output.length); + // Calculate the total length of all output entries and the individual max + // entries length of all output entries. We have to remove colors first, + // otherwise the length would not be calculated properly. + for (; i < output.length; i++) { + const len = ctx.colors ? removeColors(output[i]).length : output[i].length; + dataLen[i] = len; + totalLength += len; + if (maxLength < len) + maxLength = len; + } + // Add two to `maxLength` as we add a single whitespace character plus a comma + // in-between two entries. + const actualMax = maxLength + 2; + // Check if at least three entries fit next to each other and prevent grouping + // of arrays that contains entries of very different length (i.e., if a single + // entry is longer than 1/5 of all other entries combined). Otherwise the + // space in-between small entries would be enormous. + if (actualMax * 3 + ctx.indentationLvl < ctx.breakLength && + (totalLength / maxLength > 5 || maxLength <= 6)) { + + const approxCharHeights = 2.5; + const bias = 1; + // Dynamically check how many columns seem possible. + const columns = Math.min( + // Ideally a square should be drawn. We expect a character to be about 2.5 + // times as high as wide. This is the area formula to calculate a square + // which contains n rectangles of size `actualMax * approxCharHeights`. + // Divide that by `actualMax` to receive the correct number of columns. + // The added bias slightly increases the columns for short entries. + Math.round( + Math.sqrt( + approxCharHeights * (actualMax - bias) * output.length + ) / (actualMax - bias) + ), + // Limit array grouping for small `compact` modes as the user requested + // minimal grouping. + ctx.compact * 3, + // Limit the columns to a maximum of ten. + 10 + ); + // Return with the original output if no grouping should happen. + if (columns <= 1) { + return output; + } + // Calculate the maximum length of all entries that are visible in the first + // column of the group. + const tmp = []; + let firstLineMaxLength = dataLen[0]; + for (i = columns; i < dataLen.length; i += columns) { + if (dataLen[i] > firstLineMaxLength) + firstLineMaxLength = dataLen[i]; + } + // Each iteration creates a single line of grouped entries. + for (i = 0; i < output.length; i += columns) { + // Calculate extra color padding in case it's active. This has to be done + // line by line as some lines might contain more colors than others. + let colorPadding = output[i].length - dataLen[i]; + // Add padding to the first column of the output. + let str = output[i].padStart(firstLineMaxLength + colorPadding, ' '); + // The last lines may contain less entries than columns. + const max = Math.min(i + columns, output.length); + for (var j = i + 1; j < max; j++) { + colorPadding = output[j].length - dataLen[j]; + str += `, ${output[j].padStart(maxLength + colorPadding, ' ')}`; + } + tmp.push(str); + } + output = tmp; + } + return output; +} + function handleMaxCallStackSize(ctx, err, constructor, tag, indentationLvl) { if (isStackOverflowError(err)) { ctx.seen.pop(); @@ -1205,50 +1309,58 @@ function formatProperty(ctx, value, recurseTimes, key, type) { return `${name}:${extra}${str}`; } +function isBelowBreakLength(ctx, output, start) { + // Each entry is separated by at least a comma. Thus, we start with a total + // length of at least `output.length`. In addition, some cases have a + // whitespace in-between each other that is added to the total as well. + let totalLength = output.length + start; + if (totalLength + output.length > ctx.breakLength) + return false; + for (var i = 0; i < output.length; i++) { + if (ctx.colors) { + totalLength += removeColors(output[i]).length; + } else { + totalLength += output[i].length; + } + if (totalLength > ctx.breakLength) { + return false; + } + } + return true; +} + function reduceToSingleString(ctx, output, base, braces, combine = false) { - const breakLength = ctx.breakLength; - let i = 0; if (ctx.compact !== true) { if (combine) { - const totalLength = output.reduce((sum, cur) => sum + cur.length, 0); - if (totalLength + output.length * 2 < breakLength) { - let res = `${base ? `${base} ` : ''}${braces[0]} `; - for (; i < output.length - 1; i++) { - res += `${output[i]}, `; - } - res += `${output[i]} ${braces[1]}`; - return res; + // Line up all entries on a single line in case the entries do not exceed + // `breakLength`. Add 10 as constant to start next to all other factors + // that may reduce `breakLength`. + const start = output.length + ctx.indentationLvl + + braces[0].length + base.length + 10; + if (isBelowBreakLength(ctx, output, start)) { + return `${base ? `${base} ` : ''}${braces[0]} ${join(output, ', ')} ` + + braces[1]; } } + // Line up each entry on an individual line. const indentation = `\n${' '.repeat(ctx.indentationLvl)}`; - let res = `${base ? `${base} ` : ''}${braces[0]}${indentation} `; - for (; i < output.length - 1; i++) { - res += `${output[i]},${indentation} `; - } - res += `${output[i]}${indentation}${braces[1]}`; - return res; + return `${base ? `${base} ` : ''}${braces[0]}${indentation} ` + + `${join(output, `,${indentation} `)}${indentation}${braces[1]}`; } - if (output.length * 2 <= breakLength) { - let length = 0; - for (; i < output.length && length <= breakLength; i++) { - if (ctx.colors) { - length += removeColors(output[i]).length + 1; - } else { - length += output[i].length + 1; - } - } - if (length <= breakLength) - return `${braces[0]}${base ? ` ${base}` : ''} ${join(output, ', ')} ` + - braces[1]; + // Line up all entries on a single line in case the entries do not exceed + // `breakLength`. + if (isBelowBreakLength(ctx, output, 0)) { + return `${braces[0]}${base ? ` ${base}` : ''} ${join(output, ', ')} ` + + braces[1]; } + const indentation = ' '.repeat(ctx.indentationLvl); // If the opening "brace" is too large, like in the case of "Set {", // we need to force the first item to be on the next line or the // items will not line up correctly. - const indentation = ' '.repeat(ctx.indentationLvl); const ln = base === '' && braces[0].length === 1 ? ' ' : `${base ? ` ${base}` : ''}\n${indentation} `; - const str = join(output, `,\n${indentation} `); - return `${braces[0]}${ln}${str} ${braces[1]}`; + // Line up each entry on an individual line. + return `${braces[0]}${ln}${join(output, `,\n${indentation} `)} ${braces[1]}`; } module.exports = { diff --git a/test/parallel/test-util-inspect.js b/test/parallel/test-util-inspect.js index fdba7ebe1b8eb9..e345967ad3f812 100644 --- a/test/parallel/test-util-inspect.js +++ b/test/parallel/test-util-inspect.js @@ -1966,3 +1966,196 @@ assert.strictEqual( '{ foo: [Getter/Setter] Set { [ [Object], 2, {} ], ' + "'foobar', { x: 1 } },\n inc: [Getter: NaN] }"); } + +// Check compact number mode. +{ + let obj = { + a: { + b: { + x: 5, + c: { + x: '10000000000000000 00000000000000000 '.repeat(1e1), + d: 2, + e: 3 + } + } + }, + b: [ + 1, + 2, + [ 1, 2, { a: 1, b: 2, c: 3 } ] + ], + c: ['foo', 4, 444444], + d: Array.from({ length: 100 }).map((e, i) => { + return i % 2 === 0 ? i * i : i; + }), + e: Array(6).fill('foobar'), + f: Array(9).fill('foobar'), + g: Array(21).fill('foobar baz'), + h: [100].concat(Array.from({ length: 9 }).map((e, n) => (n))), + long: Array(9).fill('This text is too long for grouping!') + }; + + let out = util.inspect(obj, { compact: 3, depth: 10 }); + + let expected = [ + '{', + ' a: {', + ' b: {', + ' x: 5,', + ' c: {', + " x: '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ' +", + " '10000000000000000 00000000000000000 ',", + ' d: 2,', + ' e: 3', + ' }', + ' }', + ' },', + ' b: [ 1, 2, [ 1, 2, { a: 1, b: 2, c: 3 } ] ],', + " c: [ 'foo', 4, 444444 ],", + ' d: [', + ' 0, 1, 4, 3, 16, 5, 36,', + ' 7, 64, 9, 100, 11, 144, 13,', + ' 196, 15, 256, 17, 324, 19, 400,', + ' 21, 484, 23, 576, 25, 676, 27,', + ' 784, 29, 900, 31, 1024, 33, 1156,', + ' 35, 1296, 37, 1444, 39, 1600, 41,', + ' 1764, 43, 1936, 45, 2116, 47, 2304,', + ' 49, 2500, 51, 2704, 53, 2916, 55,', + ' 3136, 57, 3364, 59, 3600, 61, 3844,', + ' 63, 4096, 65, 4356, 67, 4624, 69,', + ' 4900, 71, 5184, 73, 5476, 75, 5776,', + ' 77, 6084, 79, 6400, 81, 6724, 83,', + ' 7056, 85, 7396, 87, 7744, 89, 8100,', + ' 91, 8464, 93, 8836, 95, 9216, 97,', + ' 9604, 99', + ' ],', + ' e: [', + " 'foobar',", + " 'foobar',", + " 'foobar',", + " 'foobar',", + " 'foobar',", + " 'foobar'", + ' ],', + ' f: [', + " 'foobar', 'foobar',", + " 'foobar', 'foobar',", + " 'foobar', 'foobar',", + " 'foobar', 'foobar',", + " 'foobar'", + ' ],', + ' g: [', + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz', 'foobar baz',", + " 'foobar baz'", + ' ],', + ' h: [', + ' 100, 0, 1,', + ' 2, 3, 4,', + ' 5, 6, 7,', + ' 8', + ' ],', + ' long: [', + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!',", + " 'This text is too long for grouping!'", + ' ]', + '}' + ].join('\n'); + + assert.strictEqual(out, expected); + + // Verify that array grouping and line consolidation does not happen together. + obj = { + a: { + b: { + x: 5, + c: { + d: 2, + e: 3 + } + } + }, + b: Array.from({ length: 9 }).map((e, n) => { + return n % 2 === 0 ? 'foobar' : 'baz'; + }) + }; + + out = util.inspect(obj, { compact: 1, breakLength: Infinity, colors: true }); + + expected = [ + '{', + ' a: {', + ' b: { x: \u001b[33m5\u001b[39m, c: \u001b[36m[Object]\u001b[39m }', + ' },', + ' b: [', + " \u001b[32m'foobar'\u001b[39m, \u001b[32m'baz'\u001b[39m,", + " \u001b[32m'foobar'\u001b[39m, \u001b[32m'baz'\u001b[39m,", + " \u001b[32m'foobar'\u001b[39m, \u001b[32m'baz'\u001b[39m,", + " \u001b[32m'foobar'\u001b[39m, \u001b[32m'baz'\u001b[39m,", + " \u001b[32m'foobar'\u001b[39m", + ' ]', + '}', + ].join('\n'); + + assert.strictEqual(out, expected); + + obj = Array.from({ length: 60 }).map((e, i) => i); + out = util.inspect(obj, { compact: 1, breakLength: Infinity, colors: true }); + + expected = [ + '[', + ' \u001b[33m0\u001b[39m, \u001b[33m1\u001b[39m, \u001b[33m2\u001b[39m,', + ' \u001b[33m3\u001b[39m, \u001b[33m4\u001b[39m, \u001b[33m5\u001b[39m,', + ' \u001b[33m6\u001b[39m, \u001b[33m7\u001b[39m, \u001b[33m8\u001b[39m,', + ' \u001b[33m9\u001b[39m, \u001b[33m10\u001b[39m, \u001b[33m11\u001b[39m,', + ' \u001b[33m12\u001b[39m, \u001b[33m13\u001b[39m, \u001b[33m14\u001b[39m,', + ' \u001b[33m15\u001b[39m, \u001b[33m16\u001b[39m, \u001b[33m17\u001b[39m,', + ' \u001b[33m18\u001b[39m, \u001b[33m19\u001b[39m, \u001b[33m20\u001b[39m,', + ' \u001b[33m21\u001b[39m, \u001b[33m22\u001b[39m, \u001b[33m23\u001b[39m,', + ' \u001b[33m24\u001b[39m, \u001b[33m25\u001b[39m, \u001b[33m26\u001b[39m,', + ' \u001b[33m27\u001b[39m, \u001b[33m28\u001b[39m, \u001b[33m29\u001b[39m,', + ' \u001b[33m30\u001b[39m, \u001b[33m31\u001b[39m, \u001b[33m32\u001b[39m,', + ' \u001b[33m33\u001b[39m, \u001b[33m34\u001b[39m, \u001b[33m35\u001b[39m,', + ' \u001b[33m36\u001b[39m, \u001b[33m37\u001b[39m, \u001b[33m38\u001b[39m,', + ' \u001b[33m39\u001b[39m, \u001b[33m40\u001b[39m, \u001b[33m41\u001b[39m,', + ' \u001b[33m42\u001b[39m, \u001b[33m43\u001b[39m, \u001b[33m44\u001b[39m,', + ' \u001b[33m45\u001b[39m, \u001b[33m46\u001b[39m, \u001b[33m47\u001b[39m,', + ' \u001b[33m48\u001b[39m, \u001b[33m49\u001b[39m, \u001b[33m50\u001b[39m,', + ' \u001b[33m51\u001b[39m, \u001b[33m52\u001b[39m, \u001b[33m53\u001b[39m,', + ' \u001b[33m54\u001b[39m, \u001b[33m55\u001b[39m, \u001b[33m56\u001b[39m,', + ' \u001b[33m57\u001b[39m, \u001b[33m58\u001b[39m, \u001b[33m59\u001b[39m', + ']' + ].join('\n'); + + assert.strictEqual(out, expected); + + out = util.inspect([1, 2, 3, 4], { compact: 1, colors: true }); + expected = '[ \u001b[33m1\u001b[39m, \u001b[33m2\u001b[39m, ' + + '\u001b[33m3\u001b[39m, \u001b[33m4\u001b[39m ]'; + + assert.strictEqual(out, expected); +}