diff --git a/index.js b/index.js index f58a2b4..2467025 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ const { hasOwnProperty } = Object.prototype +/** @typedef {{toJSON: (key: string) => unknown}} ToJSON */ + const stringify = configure() // @ts-expect-error @@ -394,9 +396,18 @@ function configure (options) { } } + /** + * @param {string | number} key + * @param {unknown} value + * @param {object[]} stack + * @param {Set} replacer + * @param {string} spacer + * @param {string} indentation + */ let stringifyArrayReplacer = function (key, value, stack, replacer, spacer, indentation) { - if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') { - value = safe ? callSafe(value.toJSON, value, key) : value.toJSON(key) + if (typeof value === 'object' && value !== null && typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) } switch (typeof value) { @@ -431,11 +442,11 @@ function configure (options) { const maximumValuesToStringify = Math.min(value.length, maximumBreadth) let i = 0 for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation) + const tmp = stringifyArrayReplacer(i, value[i], stack, replacer, spacer, indentation) res += tmp !== undefined ? tmp : 'null' res += join } - const tmp = stringifyArrayReplacer(String(i), value[i], stack, replacer, spacer, indentation) + const tmp = stringifyArrayReplacer(i, value[i], stack, replacer, spacer, indentation) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -459,7 +470,7 @@ function configure (options) { } let separator = '' for (const key of replacer) { - const tmp = stringifyArrayReplacer(key, value[key], stack, replacer, spacer, indentation) + const tmp = stringifyArrayReplacer(key, /** @type {Record} */ (value)[key], stack, replacer, spacer, indentation) if (tmp !== undefined) { res += `${separator}${strEscape(key)}:${whitespace}${tmp}` separator = join @@ -490,6 +501,13 @@ function configure (options) { } } + /** + * @param {string | number} key + * @param {unknown} value + * @param {object[]} stack + * @param {string} spacer + * @param {string} indentation + */ let stringifyIndent = function (key, value, stack, spacer, indentation) { switch (typeof value) { case 'string': @@ -498,8 +516,9 @@ function configure (options) { if (value === null) { return 'null' } - if (typeof value.toJSON === 'function') { - value = safe ? callSafe(value.toJSON, value, key) : value.toJSON(key) + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) // Prevent calling `toJSON` again. if (typeof value !== 'object') { return stringifyIndent(key, value, stack, spacer, indentation) @@ -528,11 +547,11 @@ function configure (options) { const maximumValuesToStringify = Math.min(value.length, maximumBreadth) let i = 0 for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation) + const tmp = stringifyIndent(i, value[i], stack, spacer, indentation) res += tmp !== undefined ? tmp : 'null' res += join } - const tmp = stringifyIndent(String(i), value[i], stack, spacer, indentation) + const tmp = stringifyIndent(i, value[i], stack, spacer, indentation) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -558,9 +577,9 @@ function configure (options) { let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) const typedArrayType = getTypedArrayType(value) if (typedArrayType !== undefined) { - res += stringifyTypedArray(value, join, maximumBreadth, bigint, fail, typedArrayType) - keys = keys.slice(value.length) - maximumPropertiesToStringify -= value.length + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, fail, typedArrayType) + keys = keys.slice(/** @type {{length: number}} */ (value).length) + maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length separator = join } if (deterministic) { @@ -569,7 +588,7 @@ function configure (options) { stack.push(value) for (let i = 0; i < maximumPropertiesToStringify; i++) { const key = keys[i] - const tmp = stringifyIndent(key, value[key], stack, spacer, indentation) + const tmp = stringifyIndent(key, /** @type {Record} */ (value)[key], stack, spacer, indentation) if (tmp !== undefined) { res += `${separator}${strEscape(key)}: ${tmp}` separator = join @@ -605,6 +624,11 @@ function configure (options) { } } + /** + * @param {string | number} key + * @param {unknown} value + * @param {object[]} stack + */ let stringifySimple = function (key, value, stack) { switch (typeof value) { case 'string': @@ -613,8 +637,9 @@ function configure (options) { if (value === null) { return 'null' } - if (typeof value.toJSON === 'function') { - value = safe ? callSafe(value.toJSON, value, key) : value.toJSON(key) + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) // Prevent calling `toJSON` again if (typeof value !== 'object') { return stringifySimple(key, value, stack) @@ -630,7 +655,7 @@ function configure (options) { let res = '' - const hasLength = value.length !== undefined + const hasLength = /** @type {{length?: unknown}} */ (value).length !== undefined if (hasLength && Array.isArray(value)) { if (value.length === 0) { return '[]' @@ -642,11 +667,11 @@ function configure (options) { const maximumValuesToStringify = Math.min(value.length, maximumBreadth) let i = 0 for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifySimple(String(i), value[i], stack) + const tmp = stringifySimple(i, value[i], stack) res += tmp !== undefined ? tmp : 'null' res += ',' } - const tmp = stringifySimple(String(i), value[i], stack) + const tmp = stringifySimple(i, value[i], stack) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -668,9 +693,9 @@ function configure (options) { let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) const typedArrayType = hasLength ? getTypedArrayType(value) : undefined if (typedArrayType !== undefined) { - res += stringifyTypedArray(value, ',', maximumBreadth, bigint, fail, typedArrayType) - keys = keys.slice(value.length) - maximumPropertiesToStringify -= value.length + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType) + keys = keys.slice(/** @type {{length: number}} */ (value).length) + maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length separator = ',' } if (deterministic) { @@ -679,7 +704,7 @@ function configure (options) { stack.push(value) for (let i = 0; i < maximumPropertiesToStringify; i++) { const key = keys[i] - const tmp = stringifySimple(key, value[key], stack) + const tmp = stringifySimple(key, /** @type {Record} */ (value)[key], stack) if (tmp !== undefined) { res += `${separator}${strEscape(key)}:${tmp}` separator = ',' diff --git a/test.js b/test.js index 7da2985..c95408c 100644 --- a/test.js +++ b/test.js @@ -217,29 +217,47 @@ test('circular depths can have multiple digits', /** @param {import('tap').Test} assert.end() }) -test('toJSON receives array keys as string', function (assert) { - const object = [{ - toJSON (key) { - assert.equal(key, '0') - return 42 +test('toJSON receives array keys as string', /** @param {import('tap').Test} assert */ (assert) => { + for (const safe of [false, true]) { + const serializer = stringify.configure({ safe }) + for (const replacer of [undefined, identityReplacer, ['nested']]) { + for (const space of [undefined, 2]) { + const keys = [] + const array = new Array(1001) + for (const index of [0, 10, 1000]) { + array[index] = { + /** @param {string} key */ + toJSON (key) { + keys.push(key) + return index + } + } + } + array[1] = { + /** @param {string} key */ + toJSON (key) { + keys.push(key) + return { + nested: [{ + /** @param {string} key */ + toJSON (key) { + keys.push(key) + return null + } + }] + } + } + } + const expected = new Array(1001) + expected[0] = 0 + expected[1] = { nested: [null] } + expected[10] = 10 + expected[1000] = 1000 + assert.equal(serializer(array, replacer, space), JSON.stringify(expected, null, space)) + assert.strictSame(keys, ['0', '1', '0', '10', '1000']) + } } - }] - - const expected = JSON.stringify(object) - - let actual = stringify(object) - assert.equal(actual, expected) - - actual = stringify(object, ['0']) - assert.equal(actual, expected) - - // @ts-expect-error - actual = stringify(object, (key, value) => value) - assert.equal(actual, expected) - - actual = stringify(object, null, 2) - assert.equal(actual, '[\n 42\n]') - + } assert.end() }) @@ -800,8 +818,10 @@ test('bigint option as string', function (assert) { assert.end() }) -test('bigint option invalid values', function (assert) { +test('bigint option invalid values', /** @param {import('tap').Test} assert */ function (assert) { + // @ts-expect-error assert.throws(() => stringify.configure({ bigint: 'nope' }), /bigint/) + // @ts-expect-error assert.throws(() => stringify.configure({ bigint: 1 }), /bigint/) assert.end() }) @@ -1613,7 +1633,8 @@ test('safe option defaults and explicit false', function (assert) { assert.end() }) -test('safe option must be boolean', function (assert) { +test('safe option must be boolean', /** @param {import('tap').Test} assert */ function (assert) { + // @ts-expect-error assert.throws(() => stringify.configure({ safe: 'yes' }), /safe/) assert.end() })