Source: util/sorted-stringify.ts

/**
 * @author Michael Hasenstein <hasenstein@yahoo.com>
 * @copyright REFINIO GmbH 2017
 * @license CC-BY-NC-SA-2.5; portions MIT License
 * @version 0.0.1
 */

/* eslint-disable @typescript-eslint/no-unsafe-call */

/**
 * @module
 */

import {createError} from '../errors';
import type {AnyFunction} from './function';
import type {AnyObject} from './object';
import {getObjTypeName, isFunction, isObject, isString, isSymbol} from './type-checks-basic';

/**
 * Internal JSON string build function for (pure) objects and for Error objects (common code
 * except for the way to find the object keys, Object.keys() vs. Reflect.ownKeys())
 * @private
 * @param {object} obj
 * @param {string[]} keys - MUTATED (sorted in place)
 * @param {Map<*,null|string>} seenObjects - Here it is never `null` because this function only is
 * called for objects, and when the thing to stringify is an object there always is a `Set` on
 * this parameter in the parent function `actualStringify`
 * @param {boolean} convertUnconvertible - When `true` circles, promises, functions and symbols
 * are meta-encoded instead of reported with an error
 * @param {string} property - When called for a nested sub-object this is the property name of
 * the parent object. It is `null` when called for the root object.
 * @returns {string}
 */
function buildObjString(
    obj: Readonly<AnyObject>,
    keys: string[],
    seenObjects: Map<unknown, null | string>,
    convertUnconvertible: boolean,
    property: null | string
): string {
    // Leads to predictable insertion-order independent iteration sequence
    keys.sort();

    let jsonStr = '{';

    for (const key of keys) {
        // Compatibility with JSON.stringify: object properties that are undefined or point to a
        // symbol are excluded
        if (obj[key] !== undefined && (!isSymbol(obj[key]) || convertUnconvertible)) {
            // If a property is undefined it is skipped, so checking what we added to the JSON
            // string thus far is a better option than the others that I thought of, including
            // building an array and then calling join(',') on it (wasted memory allocation).
            if (!jsonStr.endsWith('{') && !jsonStr.endsWith(',')) {
                jsonStr += ',';
            }

            jsonStr +=
                '"' +
                key +
                '":' +
                actualStringify(
                    obj[key],
                    convertUnconvertible,
                    // BRANCHING: Each item creates a different branch and therefore needs a
                    // copy, otherwise the branches would detect objects in another branch which
                    // are not a circle.
                    // Optimization: The (expensive) clone operation is only needed for objects.
                    isObject(obj[key]) ? new Map(seenObjects) : null,
                    property === null ? key : `${property}.${key}`
                );
        }
    }

    jsonStr += '}';

    return jsonStr;
}

// Alternative typing:
// function actualStringify<T extends unknown> (
//     obj: T,
//     seenObjects: T extends AnyObject ? Map<unknown,null|string> : null
// ): string {

/**
 * Inner stringify function: The "seenObjects" parameter is not visible in the public function.
 * @private
 * @param {*} obj - A value or an object.
 * @param {boolean} convertUnconvertible - When `true` circles, promises, functions and symbols
 * are meta-encoded instead of reported with an error
 * @param {Map<*,null|string>|null} seenObjects - This is set to `null` when the `obj` parameter
 * is not an object, otherwise it is a Set
 * @param {null|string} property - When called for a nested sub-object this is the property name of
 * the parent object. It is `null` when called for the root object.
 * @param {boolean} [unorderedArray=false] - This parameter is used during recursion for Set and
 * Map objects: Those objects are turned into arrays, for which the function is then called
 * recursively. Since Set and Map objects are unordered but are iterated over in insertion order
 * we could end up with differently ordered arrays and therefore with different JSON strings.
 * That is why if we get an array from such a recursive call we need to order it. The best way
 * to do this is to order the JSON strings of the elements. That is why the array cannot be
 * pre-ordered after converting Set and Map to their respective array presentations - it would
 * not work for most object elements. We can only reliably order all kinds of Set and Map array
 * representations if we order the JSON strings of their elements. *Regular arrays* in the
 * object itself must of course not be ordered.
 * @returns {string} Returns a JSON string
 */
function actualStringify(
    obj: unknown,
    convertUnconvertible: boolean,
    seenObjects: null | Map<unknown, null | string>,
    property: null | string,
    unorderedArray: boolean = false
): string {
    // Circle detection - Like the native stringify function we do not handle circles, but we want
    // to detect them early and not through a stack overflow.
    // "null" is used as part of an optimization: When the value is an object (or array) we will
    // need to clone seenObjects for very sub-item (if it is an object). If it is a simple value it
    // is not used at all and cloning the Set would be useless. So if seenObjects is null there
    // already was an isObject check before the recursive call to this function.
    if (seenObjects !== null) {
        const seen = seenObjects.get(obj);

        // obj is an object and not a simple value, and it could lead to a circle through its
        // sub-items (array items or object properties can be objects we already encountered)
        if (seen !== undefined) {
            if (convertUnconvertible) {
                return `"$$CIRCLE:${seen === null ? '/' : seen}$$"`;
            } else {
                throw createError('USS-STR1', {property});
            }
        }

        seenObjects.set(obj, property);
    }

    const objName = getObjTypeName(obj);

    switch (objName) {
        case 'Array': {
            const stringifiedItems = [];

            // WE CANNOT USE Array.prototype.map: map() skips over undefined array items, but
            // JSON.stringify of an array with holes produces "null" for each hole. We MUST use
            // a loop that does not skip undefined array items.
            for (const item of obj as any[]) {
                // Each call needs an independent copy of "seenObjects"
                stringifiedItems.push(
                    actualStringify(
                        item,
                        convertUnconvertible,
                        // BRANCHING: Each item creates a different branch and therefore needs a
                        // copy, otherwise the branches would detect objects in another branch which
                        // are not a circle.
                        // Optimization: The (expensive) clone operation is only needed for objects.
                        isObject(item) ? new Map(seenObjects as Map<unknown, string>) : null,
                        property
                    )
                );
            }

            if (unorderedArray) {
                // The sort() method sorts the elements of an array IN PLACE and returns the
                // sorted array. The default sort order is ascending, built upon converting the
                // elements into strings, then comparing their sequences of UTF-16 code units
                // values.
                stringifiedItems.sort();
            }

            return '[' + stringifiedItems.join(',') + ']';
        }

        case 'Object':
            // 1. toJSON() does *not* have to return a string, so its return value still has to be
            // stringified. However, it can be anything, so we have to call the main stringifier
            // can can't call buildObjString in case toJSON() does not return an object.
            // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior
            // 2. Note about circle detection: The object returned by toJSON() will probably be a
            // new (but identical) one each time so circle detection would fail, but it was already
            // done before we get here using the original object.
            return isFunction((obj as AnyObject).toJSON)
                ? actualStringify(
                      (obj as AnyObject).toJSON(),
                      convertUnconvertible,
                      seenObjects,
                      property
                  )
                : buildObjString(
                      obj as AnyObject,
                      Reflect.ownKeys(obj as AnyObject).filter(isString),
                      seenObjects as Map<unknown, null | string>,
                      convertUnconvertible,
                      property
                  );

        case 'Error':
            // About the "keys" (2nd parameter):
            // Make sure "name", "message" and "stack" are in the array because during testing
            // Firefox did not have "stack" and the name (e.g. "Error" was missing too; also
            // make sure they are in the array of properties only once, that's why we go through
            // a Set object conversion and back to an array
            return buildObjString(
                obj as AnyObject,
                [
                    // Guaranteed to be included on any platform:
                    'name',
                    'message',
                    'stack',
                    // Any additional Error object properties
                    ...Reflect.ownKeys(obj as Error).filter(isString)
                ].filter((item, index, arr) => arr.indexOf(item) === index),
                seenObjects as Map<unknown, null | string>,
                convertUnconvertible,
                property
            );

        case 'RegExp':
            return JSON.stringify(new RegExp(obj as RegExp).source);
        case 'Date':
            return '"' + (obj as Date).toJSON() + '"';

        case 'Set':
            // seenObjects always is a Set when obj is an object (null only for primitive types)
            return actualStringify(
                Array.from(obj as Set<any>),
                convertUnconvertible,
                seenObjects as Map<unknown, null | string>,
                property,
                true
            );

        case 'Map':
            return actualStringify(
                Array.from(obj as Map<any, any>),
                convertUnconvertible,
                seenObjects as Map<unknown, null | string>,
                property,
                true
            );

        case 'Null':
        case 'Undefined':
            // Same as JSON.stringify for compatibility. The special case when stringify() gets
            // "undefined" as input is handled in the exported parent function, the case where
            // object property values are undefined is handled in buildObjString()
            return 'null';

        case 'Function':
            return '[FUNCTION] ' + (obj as AnyFunction).toString();

        // case 'Function':
        case 'Promise':
            return '';

        case 'Symbol': {
            if (convertUnconvertible) {
                return `"$$$SYMBOL:${String(obj)}$$$"`;
            } else {
                // Error message parameter: We cannot pass the entire object - createError() will
                // call sortedStringify and cause a loop
                throw createError('USS-STR4', {obj: objName});
            }
        }

        default:
            // All simple types incl. special values such as NaN
            return JSON.stringify(obj);
    }
}

/**
 * A deterministic version of `JSON.stringify` that always creates the exact same string for the
 * same data. It also handles Map and Set objects for which the native method returns an empty
 * object "{}" by converting them to arrays.
 *
 * ## Features
 *
 * - Circle detection: By default and if the 2nd parameter "`convertCircles`" is `false` a
 *   circular structure results in an error. However, if the parameter is set to `true` circles
 *   will be converted into meta-information inside the JSON string. This can be used either for
 *   debug or error output, where a circle should be reported rather than preventing all output,
 *   or it can be used by a recipient to recreate the circle when reviving an object from the
 *   JSON string.
 * - Determinism is achieved by sorting object keys instead of using the natural Javascript
 *   iteration sequence determined by property insertion order.
 * - Supports everything native `JSON.stringify` does, except that...
 * - Like `JSON.stringify`, ES 2015 symbols are not supported, but unlike the standard method ours
 *   will throw an error when encountering a symbol instead of quietly treating it as `undefined`.
 * - In addition stringifies functions (relying on function object's `toString()` method), `Map`
 *   and `Set` objects, `Error` objects. To recreate the original objects a _reviver_ function
 *   fill be needed for `JSON.parse` for these non-standard objects.
 * - `Map` and `Set` objects are simply represented as arrays, so the reviver function will have
 *   to know which properties are of those types. This stringifier does not add any meta
 *   information that a reviver could use to learn about such types. Since the main purpose of
 *   this function is to stringify values of ONE objects for microdata representation this is
 *   good enough. The reviver can (and does) use the type information in the ONE object recipes.
 * - **Insertion order is lost:** The array representation of `Map` and `Set` will be sorted (each
 *   array item's string representation is used for this). The keys of objects being stringified
 *   are sorted as well. This is to solve the problem that the array representation is
 *   insertion-order dependent even though Map and Set objects are unordered, because iteration
 *   order of objects in Javascript respects insertion order. **This means that any code relying
 *   on maintaining the original insertion order will fail!**
 * - Just like `JSON.stringify`, only enumerable properties are included.
 *
 * ## Performance
 *
 * Testing on node.js 7.10 showed this function takes about twice as long as the native method.
 * On IE Edge and on Firefox 53 it took 10 times as long or worse. For comparison:
 *
 * - Package {@link https://github.com/Kikobeats/json-stringify-deterministic} took over five
 *   times as long as this code.
 * - Package {@link https://github.com/substack/json-stable-stringify} took more than twice as
 *   long.
 *
 * See {@link https://abdulapopoola.com/2017/02/27/what-you-didnt-know-about-json-stringify/}
 * for information about some idiosyncrasies of JSON conversion in JavaScript.
 * @static
 * @param {*} obj - A value or an object.
 * @returns {string} Returns a JSON string
 * @throws {Error} Throws an error if a circle is detected or if a Function, Promise or Symbol
 * is detected.
 */
export function stringify<T extends void | unknown>(obj: unknown): T extends void ? void : string {
    // RETURN TYPE CASTS for the conditional function return type as recommended here:
    // https://github.com/Microsoft/TypeScript/issues/22735#issuecomment-374817151

    if (obj === undefined) {
        return undefined as T extends void ? void : string;
    }

    // Use an inner function to hide the internal circle detection array parameter and for the
    // special undefined value return that is only used for the parent value.
    return actualStringify(obj, false, isObject(obj) ? new Map() : null, null) as T extends void
        ? void
        : string;
}

/**
 * Same as {@link stringify}, but when a circle is detected it is meta-encoded in the JSON
 * string result instead of throwing an error. Recreating the original object from that JSON
 * string will require a special reviver that uses the metadata to recreate the circle.
 * The main use case though is when this stringifier is used to create readable string output
 * for errors messages or for debugging. In those cases knowing that there is a circle is
 * infinitely better than getting another error from inside the original error because some
 * object that was meant to be part of the error message cold not be stringified because of a
 * circle.
 * @static
 * @param {*} obj - A value or an object.
 * @returns {string} Returns a JSON string
 */
export function stringifyWithCircles<T extends void | unknown>(
    obj: unknown
): T extends void ? void : string {
    // RETURN TYPE CASTS for the conditional function return type as recommended here:
    // https://github.com/Microsoft/TypeScript/issues/22735#issuecomment-374817151

    if (obj === undefined) {
        return undefined as T extends void ? void : string;
    }

    // Use an inner function to hide the internal circle detection array parameter and for the
    // special undefined value return that is only used for the parent value.
    return actualStringify(obj, true, isObject(obj) ? new Map() : null, null) as T extends void
        ? void
        : string;
}