/**
* @module json-stringify-raw
* @copyright Copyright 2020 Kevin Locke <kevin@kevinlocke.name>
* @license MIT
*/
'use strict';
// Note: object stack is global state to handle replacer calling stringify.
const stack = [];
// Note: @this doesn't allow documentation beyond type. Doc in @description.
// See https://github.com/jsdoc/jsdoc/issues/1782
//
// Note: @callback (and @param references) must declare module explicitly
// See https://github.com/jsdoc/jsdoc/issues/356
/** Function to selectively replace values in JSON output.
*
* Called with <code>this</code> set to the object on which the value is a
* property.
*
* @callback module:json-stringify-raw~replacerFunction
* @param {string} key Name of property value to replace, or empty string for
* a value which was not passed as a property.
* @param {*} value Value to replace.
* @returns {?string|boolean} A string which represents the value in the JSON,
* a boolean to include or exclude the property from the JSON, or
* {@link null} or {@link undefined} to indicate that the value should be
* stringified normally, without replacement. Any other value will cause
* {@link TypeError} to be thrown.
*/
/** Converts a given array to a JSON string, using a given replacer, gap, and
* indent.
*
* @private
* @param {!Array} array Array to be converted to JSON.
* @param {module:json-stringify-raw~replacerFunction} replacer Value replacer.
* @param {string} gap String added to indent each array element.
* @param {string} indent String used to indent the array as a whole.
* @returns {string} JSON representing value.
*/
function stringifyArray(array, replacer, gap, indent) {
const newIndent = indent + gap;
const propSep = gap ? `,\n${newIndent}` : ',';
let partial;
for (let i = 0; i < array.length; i += 1) {
// eslint-disable-next-line no-use-before-define
const strP = stringifyProperty(array, `${i}`, replacer, gap, newIndent);
if (partial === undefined) {
partial = '';
} else {
partial += propSep;
}
partial += strP === undefined ? 'null' : strP;
}
if (!partial) {
return '[]';
}
return gap ? `[\n${newIndent}${partial}\n${indent}]`
: `[${partial}]`;
}
/** Converts a given object to a JSON string, using a given replacer, gap, and
* indent.
*
* @private
* @param {!object} object Object to be converted to JSON.
* @param {module:json-stringify-raw~replacerFunction} replacer Value replacer.
* @param {string} gap String added to indent each object property.
* @param {string} indent String used to indent the object as a whole.
* @returns {string} JSON representing value.
*/
function stringifyObject(object, replacer, gap, indent) {
const newIndent = indent + gap;
const propSep = gap ? `,\n${newIndent}` : ',';
let partial = '';
Object.keys(object).forEach((key) => {
// eslint-disable-next-line no-use-before-define
const strP = stringifyProperty(object, key, replacer, gap, newIndent);
if (strP !== undefined) {
if (partial) {
partial += propSep;
}
partial += JSON.stringify(key);
partial += gap ? ': ' : ':';
partial += strP;
}
});
if (!partial) {
return '{}';
}
return gap ? `{\n${newIndent}${partial}\n${indent}}`
: `{${partial}}`;
}
/** Converts a given property of a given object to a JSON string, using a given
* replacer, gap, and indent.
*
* @private
* @param {!object} holder Object holding property to be converted to JSON.
* @param {string} key Name of property to be converted to JSON.
* @param {module:json-stringify-raw~replacerFunction} replacer Value replacer.
* @param {string} gap String added to indent each child properties.
* @param {string} indent String used to indent subsequent values.
* @returns {string=} JSON for value, or {@link undefined} if not representable.
*/
function stringifyProperty(holder, key, replacer, gap, indent) {
let value = holder[key];
if (value !== null
&& (typeof value === 'object' || typeof value === 'bigint')) {
const { toJSON } = value;
if (typeof toJSON === 'function') {
value = toJSON.call(value, key);
}
}
const replaced = replacer.call(holder, key, value);
// Replacer can act as value filter by returning boolean.
// Note: JSON.stringify uses undefined for this, but it's so convenient
// to treat undefined as "don't replace", that false was chosen instead.
if (replaced === false) {
return undefined;
}
if (typeof replaced === 'string') {
return replaced;
}
if (replaced !== undefined && replaced !== null && replaced !== true) {
throw new TypeError(
`replacer returned non-string, non-boolean value: ${replaced}`,
);
}
if (value === null) {
return 'null';
}
// Unwrap primitive wrapper objects
if (value instanceof BigInt
|| value instanceof Boolean
|| value instanceof Number
|| value instanceof String) {
value = value.valueOf();
}
switch (typeof value) {
case 'undefined': return undefined;
case 'function': return undefined;
case 'boolean': return `${value}`;
case 'number': return Number.isFinite(value) ? `${value}` : 'null';
case 'object': break;
default: return JSON.stringify(value);
}
if (stack.includes(value)) {
throw new TypeError('Converting circular structure to JSON');
}
stack.push(value);
try {
return Array.isArray(value)
? stringifyArray(value, replacer, gap, indent)
: stringifyObject(value, replacer, gap, indent);
} finally {
stack.pop();
}
}
/** Converts a given value to a JSON string, optionally using a given
* replacer function and spacing.
*
* @see {@link https://tc39.es/ecma262/#sec-numeric-types-number-tostring}
* @param {*} value Value to be converted to JSON.
* @param {?(module:json-stringify-raw~replacerFunction|Array<string|number>)=
* } replacer Optional function to selectively replace values in the JSON, or
* array of property names which will be converted to JSON.
* @param {?(number|string)=} space Number of spaces, or string added to each
* nesting level during output. If no indent is added, line breaks and
* spacing between elements is omitted. Indents are limited to 10
* characters, negatives values are ignored.
* @returns {string=} JSON for value, or {@link undefined} if not representable.
* @throws {TypeError} If value contains circular references.
* @throws {TypeError} If value contains a bigint and BigInt.prototype.toJSON
* has not been defined.
* @throws {TypeError} If replacer returns a value which is not a stirng,
* boolean, null, or undefined.
*/
module.exports =
function stringify(value, replacer, space) {
if (typeof replacer !== 'function') {
return JSON.stringify(value, replacer, space);
}
if (space instanceof Number) {
space = Number(space);
} else if (space instanceof String) {
space = String(space);
}
let gap = '';
if (typeof space === 'number') {
if (space >= 1) {
gap = ' '.slice(0, space);
}
} else if (typeof space === 'string') {
gap = space.slice(0, 10);
}
return stringifyProperty({ '': value }, '', replacer, gap, '');
};