Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 47 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const { hasOwnProperty } = Object.prototype

/** @typedef {{toJSON: (key: string) => unknown}} ToJSON */

const stringify = configure()

// @ts-expect-error
Expand Down Expand Up @@ -394,9 +396,18 @@ function configure (options) {
}
}

/**
* @param {string | number} key
* @param {unknown} value
* @param {object[]} stack
* @param {Set<string>} 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) {
Expand Down Expand Up @@ -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
Expand All @@ -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<string, unknown>} */ (value)[key], stack, replacer, spacer, indentation)
if (tmp !== undefined) {
res += `${separator}${strEscape(key)}:${whitespace}${tmp}`
separator = join
Expand Down Expand Up @@ -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':
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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<number | bigint>} */ (value), join, maximumBreadth, bigint, fail, typedArrayType)
keys = keys.slice(/** @type {{length: number}} */ (value).length)
maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length
separator = join
}
if (deterministic) {
Expand All @@ -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<string, unknown>} */ (value)[key], stack, spacer, indentation)
if (tmp !== undefined) {
res += `${separator}${strEscape(key)}: ${tmp}`
separator = join
Expand Down Expand Up @@ -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':
Expand All @@ -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)
Expand All @@ -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 '[]'
Expand All @@ -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
Expand All @@ -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<number | bigint>} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType)
keys = keys.slice(/** @type {{length: number}} */ (value).length)
maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length
separator = ','
}
if (deterministic) {
Expand All @@ -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<string, unknown>} */ (value)[key], stack)
if (tmp !== undefined) {
res += `${separator}${strEscape(key)}:${tmp}`
separator = ','
Expand Down
69 changes: 45 additions & 24 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down Expand Up @@ -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()
})
Expand Down Expand Up @@ -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()
})
Expand Down
Loading