diff --git a/CHANGELOG.md b/CHANGELOG.md index ef491cd..9f7a3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Next +- Apply `bigint: false` and strict mode to BigInt typed-array entries. +- Report strict-mode errors at their typed-array indices when `safe` is enabled. + - **Breaking change:** Require Node.js 18 or later. - Fix ESM imports in Node.js and bundlers, including applications that also use CommonJS. diff --git a/index.js b/index.js index f33f482..93a5c96 100644 --- a/index.js +++ b/index.js @@ -82,7 +82,7 @@ function wrapInQuotes (value) { * @param {string} separator * @param {number} maximumBreadth * @param {boolean | 'string'} bigint - * @param {((value: number | bigint) => never) | undefined} fail + * @param {((value: number | bigint) => string) | undefined} fail * @param {string} type */ function stringifyTypedArray (array, separator, maximumBreadth, bigint, fail, type) { @@ -90,7 +90,9 @@ function stringifyTypedArray (array, separator, maximumBreadth, bigint, fail, ty maximumBreadth = array.length } const whitespace = separator === ',' ? '' : ' ' - if (type[0] === 'F') { + const bigintDisabled = bigint === false && type[0] === 'B' + if (type[0] === 'F' || bigintDisabled) { + if (bigintDisabled && fail === undefined) return '' let value = array[0] let res = `"0":${whitespace}${Number.isFinite(value) ? value : fail ? fail(value) : 'null'}` for (let i = 1; i < maximumBreadth; i++) { @@ -282,6 +284,7 @@ function configure (options) { const maximumDepth = getPositiveIntegerOption(options, 'maximumDepth') const maximumBreadth = getPositiveIntegerOption(options, 'maximumBreadth') const safe = getBooleanOption(options, 'safe', false) + const failTypedArray = safe && fail ? makeSafe(fail) : fail let stringifyFnReplacer = function (key, parent, stack, replacer, spacer, indentation) { let value = parent[key] @@ -545,10 +548,10 @@ function configure (options) { let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) const typedArrayType = getTypedArrayType(value) if (typedArrayType !== undefined) { - res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, fail, typedArrayType) + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, failTypedArray, typedArrayType) keys = keys.slice(/** @type {{length: number}} */ (value).length) maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length - separator = join + separator = res === '' ? '' : join } if (deterministic) { keys = sort(keys, comparator) @@ -662,10 +665,10 @@ function configure (options) { let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) const typedArrayType = hasLength ? getTypedArrayType(value) : undefined if (typedArrayType !== undefined) { - res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType) + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, failTypedArray, typedArrayType) keys = keys.slice(/** @type {{length: number}} */ (value).length) maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length - separator = ',' + separator = res === '' ? '' : ',' } if (deterministic) { keys = sort(keys, comparator) diff --git a/test.js b/test.js index 8242f5e..66dde87 100644 --- a/test.js +++ b/test.js @@ -61,6 +61,7 @@ for (const name of ['Float16Array', 'Float32Array', 'Float64Array']) { /** @param {import('tap').Test} assert */ (assert) => { const strict = stringify.configure({ strict: true }) + const safeStrict = stringify.configure({ strict: true, safe: true }) for (const space of [undefined, 2]) { for (const number of [NaN, Infinity, -Infinity]) { for (const values of [[number, 1], [1, number]]) { @@ -69,6 +70,13 @@ for (const name of ['Float16Array', 'Float32Array', 'Float64Array']) { assert.throws(() => strict(value, null, space), { message: `Object can not safely be stringified. Received type number (${number})` }) + const expected = { metrics: {} } + for (let i = 0; i < values.length; i++) { + expected.metrics[i] = Number.isFinite(values[i]) + ? values[i] + : `Error: Stringification failed (Object can not safely be stringified. Received type number (${values[i]}))` + } + assert.equal(safeStrict(value, null, space), JSON.stringify(expected, null, space)) } } const finite = new TypedArray([-0, 1.5, -2]) @@ -125,6 +133,100 @@ test('integer and bigint typed arrays retain their formatting', assert.end() }) +test('bigint typed arrays apply the configured bigint policy', + /** @param {import('tap').Test} assert */ + (assert) => { + for (const TypedArray of [BigInt64Array, BigUint64Array]) { + for (const values of [[], [0n], [1n, 2n]]) { + const value = new TypedArray(values) + for (const space of [undefined, 2]) { + for (const safe of [false, true]) { + const omit = stringify.configure({ bigint: false, safe }) + assert.equal(omit(value, null, space), '{}') + assert.equal(omit({ nested: value }, null, space), JSON.stringify({ nested: {} }, null, space)) + for (const bigint of [undefined, false]) { + const strict = stringify.configure({ strict: true, bigint, safe }) + const expected = {} + for (let i = 0; i < values.length; i++) { + expected[i] = `Error: Stringification failed (Object can not safely be stringified. Received type bigint (${values[i]}))` + } + if (safe || values.length === 0) { + assert.equal(strict(value, null, space), JSON.stringify(expected, null, space)) + assert.equal(strict({ nested: value }, null, space), JSON.stringify({ nested: expected }, null, space)) + } else { + assert.throws(() => strict(value, null, space), { + message: `Object can not safely be stringified. Received type bigint (${values[0]})` + }) + } + } + for (const bigint of /** @type {const} */ ([true, 'string'])) { + const strict = stringify.configure({ strict: true, bigint, safe }) + const expected = {} + for (let i = 0; i < values.length; i++) { + expected[i] = bigint === 'string' ? String(values[i]) : Number(values[i]) + } + assert.equal(strict(value, null, space), JSON.stringify(expected, null, space)) + } + } + } + } + } + assert.end() + }) + +test('omitted bigint typed array entries preserve property order and breadth', + /** @param {import('tap').Test} assert */ + (assert) => { + for (const TypedArray of [BigInt64Array, BigUint64Array]) { + const value = Object.assign(new TypedArray(12), { z: 1, a: 2 }) + const descending = /** @param {string} left @param {string} right */ (left, right) => right.localeCompare(left) + for (const deterministic of [true, false, descending]) { + for (const maximumBreadth of [1, 11, 12, 13, 14, 15]) { + for (const safe of [false, true]) { + const serialize = stringify.configure({ bigint: false, deterministic, maximumBreadth, safe }) + const expected = {} + if (maximumBreadth > 12) { + const keys = deterministic === true ? ['a', 'z'] : ['z', 'a'] + for (const key of keys.slice(0, maximumBreadth - 12)) expected[key] = value[key] + } + if (maximumBreadth < 14) { + const count = 14 - maximumBreadth + expected['...'] = `${count} ${count === 1 ? 'item' : 'items'} not stringified` + } + for (const space of [undefined, 2]) { + assert.equal(serialize(value, null, space), JSON.stringify(expected, null, space)) + } + } + } + } + } + assert.end() + }) + +test('bigint typed array policy uses the intrinsic type', + /** @param {import('tap').Test} assert */ + (assert) => { + class BigIntSubclass extends BigInt64Array {} + const foreign = runInNewContext('new BigUint64Array([1n, 2n])') + const subclass = new BigIntSubclass([1n, 2n]) + const disguised = new BigInt64Array([1n, 2n]) + Object.defineProperties(disguised, { + constructor: { value: Int32Array }, + [Symbol.toStringTag]: { value: 'Int32Array' } + }) + const serialize = stringify.configure({ bigint: false }) + const strict = stringify.configure({ strict: true }) + for (const value of [foreign, subclass, disguised]) { + for (const space of [undefined, 2]) { + assert.equal(serialize(value, null, space), '{}') + assert.throws(() => strict(value, null, space), { + message: 'Object can not safely be stringified. Received type bigint (1)' + }) + } + } + assert.end() + }) + /** * @param {string} key * @param {unknown} value