├── .gitignore ├── tsconfig.build.json ├── SECURITY.md ├── .editorconfig ├── tsconfig.json ├── src ├── types.ts ├── array.ts ├── stringify.ts ├── quote.ts ├── object.ts ├── index.ts ├── function.ts └── index.spec.ts ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ 4 | coverage/ 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"] 5 | }, 6 | "exclude": ["**/*.spec.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@borderless/ts-scripts/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "types": ["node", "jest"], 7 | "module": "CommonJS" 8 | }, 9 | "include": ["src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Call `next()` every time you want to stringify a new value. 3 | */ 4 | export type Next = (value: any, key?: PropertyKey) => string | undefined; 5 | 6 | /** 7 | * Stringify a value. 8 | */ 9 | export type ToString = ( 10 | value: any, 11 | space: string, 12 | next: Next, 13 | key: PropertyKey | undefined 14 | ) => string | undefined; 15 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import { ToString } from "./types"; 2 | 3 | /** 4 | * Stringify an array of values. 5 | */ 6 | export const arrayToString: ToString = (array: any[], space, next) => { 7 | // Map array values to their stringified values with correct indentation. 8 | const values = array 9 | .map(function (value, index) { 10 | const result = next(value, index); 11 | 12 | if (result === undefined) return String(result); 13 | 14 | return space + result.split("\n").join(`\n${space}`); 15 | }) 16 | .join(space ? ",\n" : ","); 17 | 18 | const eol = space && values ? "\n" : ""; 19 | return `[${eol}${values}${eol}]`; 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: macos-latest 9 | strategy: 10 | matrix: 11 | node-version: 12 | - "10" 13 | - "12" 14 | - "14" 15 | - "*" 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - uses: actions/cache@v2 22 | with: 23 | path: ~/.npm 24 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 25 | restore-keys: | 26 | ${{ runner.os }}-node- 27 | - run: npm install -g npm@7 28 | - run: npm ci 29 | - run: npm test 30 | - uses: codecov/codecov-action@v1 31 | with: 32 | name: Node.js ${{ matrix.node-version }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Blake Embrey (hello@blakeembrey.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/stringify.ts: -------------------------------------------------------------------------------- 1 | import { quoteString } from "./quote"; 2 | import { Next, ToString } from "./types"; 3 | import { objectToString } from "./object"; 4 | import { functionToString } from "./function"; 5 | 6 | /** 7 | * Stringify primitive values. 8 | */ 9 | const PRIMITIVE_TYPES: Record = { 10 | string: quoteString, 11 | number: (value: number) => (Object.is(value, -0) ? "-0" : String(value)), 12 | boolean: String, 13 | symbol: (value: symbol, space: string, next: Next) => { 14 | const key = Symbol.keyFor(value); 15 | 16 | if (key !== undefined) return `Symbol.for(${next(key)})`; 17 | 18 | // ES2018 `Symbol.description`. 19 | return `Symbol(${next((value as any).description)})`; 20 | }, 21 | bigint: (value: bigint, space: string, next: Next) => { 22 | return `BigInt(${next(String(value))})`; 23 | }, 24 | undefined: String, 25 | object: objectToString, 26 | function: functionToString, 27 | }; 28 | 29 | /** 30 | * Stringify a value recursively. 31 | */ 32 | export const toString: ToString = (value, space, next, key) => { 33 | if (value === null) return "null"; 34 | 35 | return PRIMITIVE_TYPES[typeof value](value, space, next, key); 36 | }; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascript-stringify", 3 | "version": "2.1.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "Stringify is to `eval` as `JSON.stringify` is to `JSON.parse`", 8 | "license": "MIT", 9 | "repository": "https://github.com/blakeembrey/javascript-stringify.git", 10 | "author": { 11 | "name": "Blake Embrey", 12 | "email": "hello@blakeembrey.com", 13 | "url": "http://blakeembrey.me" 14 | }, 15 | "homepage": "https://github.com/blakeembrey/javascript-stringify", 16 | "bugs": { 17 | "url": "https://github.com/blakeembrey/javascript-stringify/issues" 18 | }, 19 | "main": "dist/index.js", 20 | "scripts": { 21 | "format": "ts-scripts format", 22 | "lint": "ts-scripts lint", 23 | "prepare": "ts-scripts install && ts-scripts build", 24 | "specs": "ts-scripts specs", 25 | "test": "ts-scripts test" 26 | }, 27 | "files": [ 28 | "dist/" 29 | ], 30 | "keywords": [ 31 | "stringify", 32 | "javascript", 33 | "object", 34 | "eval", 35 | "string", 36 | "code" 37 | ], 38 | "devDependencies": { 39 | "@borderless/ts-scripts": "^0.4.1", 40 | "@types/jest": "^24.0.9", 41 | "@types/node": "^11.10.4", 42 | "@types/semver": "^5.5.0", 43 | "fast-check": "^1.12.0", 44 | "semver": "^5.6.0", 45 | "typescript": "^4.2.4" 46 | }, 47 | "types": "dist/index.d.ts", 48 | "ts-scripts": { 49 | "project": "tsconfig.build.json" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/quote.ts: -------------------------------------------------------------------------------- 1 | import { Next } from "./types"; 2 | 3 | /** 4 | * Match all characters that need to be escaped in a string. Modified from 5 | * source to match single quotes instead of double. 6 | * 7 | * Source: https://github.com/douglascrockford/JSON-js/blob/master/json2.js 8 | */ 9 | const ESCAPABLE = /[\\\'\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; 10 | 11 | /** 12 | * Map of characters to escape characters. 13 | */ 14 | const META_CHARS = new Map([ 15 | ["\b", "\\b"], 16 | ["\t", "\\t"], 17 | ["\n", "\\n"], 18 | ["\f", "\\f"], 19 | ["\r", "\\r"], 20 | ["'", "\\'"], 21 | ['"', '\\"'], 22 | ["\\", "\\\\"], 23 | ]); 24 | 25 | /** 26 | * Escape any character into its literal JavaScript string. 27 | * 28 | * @param {string} char 29 | * @return {string} 30 | */ 31 | function escapeChar(char: string) { 32 | return ( 33 | META_CHARS.get(char) || 34 | `\\u${`0000${char.charCodeAt(0).toString(16)}`.slice(-4)}` 35 | ); 36 | } 37 | 38 | /** 39 | * Quote a string. 40 | */ 41 | export function quoteString(str: string) { 42 | return `'${str.replace(ESCAPABLE, escapeChar)}'`; 43 | } 44 | 45 | /** 46 | * JavaScript reserved keywords. 47 | */ 48 | const RESERVED_WORDS = new Set( 49 | ( 50 | "break else new var case finally return void catch for switch while " + 51 | "continue function this with default if throw delete in try " + 52 | "do instanceof typeof abstract enum int short boolean export " + 53 | "interface static byte extends long super char final native synchronized " + 54 | "class float package throws const goto private transient debugger " + 55 | "implements protected volatile double import public let yield" 56 | ).split(" ") 57 | ); 58 | 59 | /** 60 | * Test for valid JavaScript identifier. 61 | */ 62 | export const IS_VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; 63 | 64 | /** 65 | * Check if a variable name is valid. 66 | */ 67 | export function isValidVariableName(name: PropertyKey): name is string { 68 | return ( 69 | typeof name === "string" && 70 | !RESERVED_WORDS.has(name) && 71 | IS_VALID_IDENTIFIER.test(name) 72 | ); 73 | } 74 | 75 | /** 76 | * Quote JavaScript key access. 77 | */ 78 | export function quoteKey(key: PropertyKey, next: Next) { 79 | return isValidVariableName(key) ? key : next(key); 80 | } 81 | 82 | /** 83 | * Serialize the path to a string. 84 | */ 85 | export function stringifyPath(path: PropertyKey[], next: Next) { 86 | let result = ""; 87 | 88 | for (const key of path) { 89 | if (isValidVariableName(key)) { 90 | result += `.${key}`; 91 | } else { 92 | result += `[${next(key)}]`; 93 | } 94 | } 95 | 96 | return result; 97 | } 98 | -------------------------------------------------------------------------------- /src/object.ts: -------------------------------------------------------------------------------- 1 | import { Next, ToString } from "./types"; 2 | import { quoteKey } from "./quote"; 3 | import { USED_METHOD_KEY } from "./function"; 4 | import { arrayToString } from "./array"; 5 | 6 | /** 7 | * Transform an object into a string. 8 | */ 9 | export const objectToString: ToString = (value, space, next, key) => { 10 | // Support buffer in all environments. 11 | if (typeof Buffer === "function" && Buffer.isBuffer(value)) { 12 | return `Buffer.from(${next(value.toString("base64"))}, 'base64')`; 13 | } 14 | 15 | // Support `global` under test environments that don't print `[object global]`. 16 | if (typeof global === "object" && value === global) { 17 | return globalToString(value, space, next, key); 18 | } 19 | 20 | // Use the internal object string to select stringify method. 21 | const toString = OBJECT_TYPES[Object.prototype.toString.call(value)]; 22 | return toString ? toString(value, space, next, key) : undefined; 23 | }; 24 | 25 | /** 26 | * Stringify an object of keys and values. 27 | */ 28 | const rawObjectToString: ToString = (obj, indent, next, key) => { 29 | const eol = indent ? "\n" : ""; 30 | const space = indent ? " " : ""; 31 | 32 | // Iterate over object keys and concat string together. 33 | const values = Object.keys(obj) 34 | .reduce(function (values, key) { 35 | const fn = obj[key]; 36 | const result = next(fn, key); 37 | 38 | // Omit `undefined` object entries. 39 | if (result === undefined) return values; 40 | 41 | // String format the value data. 42 | const value = result.split("\n").join(`\n${indent}`); 43 | 44 | // Skip `key` prefix for function parser. 45 | if (USED_METHOD_KEY.has(fn)) { 46 | values.push(`${indent}${value}`); 47 | return values; 48 | } 49 | 50 | values.push(`${indent}${quoteKey(key, next)}:${space}${value}`); 51 | return values; 52 | }, [] as string[]) 53 | .join(`,${eol}`); 54 | 55 | // Avoid new lines in an empty object. 56 | if (values === "") return "{}"; 57 | 58 | return `{${eol}${values}${eol}}`; 59 | }; 60 | 61 | /** 62 | * Stringify global variable access. 63 | */ 64 | const globalToString: ToString = (value, space, next) => { 65 | return `Function(${next("return this")})()`; 66 | }; 67 | 68 | /** 69 | * Convert JavaScript objects into strings. 70 | */ 71 | const OBJECT_TYPES: Record = { 72 | "[object Array]": arrayToString, 73 | "[object Object]": rawObjectToString, 74 | "[object Error]": (error: Error, space: string, next: Next) => { 75 | return `new Error(${next(error.message)})`; 76 | }, 77 | "[object Date]": (date: Date) => { 78 | return `new Date(${date.getTime()})`; 79 | }, 80 | "[object String]": (str: string, space: string, next: Next) => { 81 | return `new String(${next(str.toString())})`; 82 | }, 83 | "[object Number]": (num: number) => { 84 | return `new Number(${num})`; 85 | }, 86 | "[object Boolean]": (bool: boolean) => { 87 | return `new Boolean(${bool})`; 88 | }, 89 | "[object Set]": (set: Set, space: string, next: Next) => { 90 | return `new Set(${next(Array.from(set))})`; 91 | }, 92 | "[object Map]": (map: Map, space: string, next: Next) => { 93 | return `new Map(${next(Array.from(map))})`; 94 | }, 95 | "[object RegExp]": String, 96 | "[object global]": globalToString, 97 | "[object Window]": globalToString, 98 | }; 99 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { toString } from "./stringify"; 2 | import { stringifyPath } from "./quote"; 3 | import { Next, ToString } from "./types"; 4 | 5 | export interface Options { 6 | maxDepth?: number; 7 | maxValues?: number; 8 | references?: boolean; 9 | skipUndefinedProperties?: boolean; 10 | } 11 | 12 | /** 13 | * Root path node. 14 | */ 15 | const ROOT_SENTINEL = Symbol("root"); 16 | 17 | /** 18 | * Stringify any JavaScript value. 19 | */ 20 | export function stringify( 21 | value: any, 22 | replacer?: ToString | null, 23 | indent?: string | number | null, 24 | options: Options = {} 25 | ) { 26 | const space = typeof indent === "string" ? indent : " ".repeat(indent || 0); 27 | const path: PropertyKey[] = []; 28 | const stack = new Set(); 29 | const tracking = new Map(); 30 | const unpack = new Map(); 31 | let valueCount = 0; 32 | 33 | const { 34 | maxDepth = 100, 35 | references = false, 36 | skipUndefinedProperties = false, 37 | maxValues = 100000, 38 | } = options; 39 | 40 | // Wrap replacer function to support falling back on supported stringify. 41 | const valueToString = replacerToString(replacer); 42 | 43 | // Every time you call `next(value)` execute this function. 44 | const onNext: Next = (value, key) => { 45 | if (++valueCount > maxValues) return; 46 | if (skipUndefinedProperties && value === undefined) return; 47 | if (path.length > maxDepth) return; 48 | 49 | // An undefined key is treated as an out-of-band "value". 50 | if (key === undefined) return valueToString(value, space, onNext, key); 51 | 52 | path.push(key); 53 | const result = builder(value, key === ROOT_SENTINEL ? undefined : key); 54 | path.pop(); 55 | return result; 56 | }; 57 | 58 | const builder: Next = references 59 | ? (value, key) => { 60 | if ( 61 | value !== null && 62 | (typeof value === "object" || 63 | typeof value === "function" || 64 | typeof value === "symbol") 65 | ) { 66 | // Track nodes to restore later. 67 | if (tracking.has(value)) { 68 | unpack.set(path.slice(1), tracking.get(value)!); 69 | // Use `undefined` as temporaray stand-in for referenced nodes 70 | return valueToString(undefined, space, onNext, key); 71 | } 72 | 73 | // Track encountered nodes. 74 | tracking.set(value, path.slice(1)); 75 | } 76 | 77 | return valueToString(value, space, onNext, key); 78 | } 79 | : (value, key) => { 80 | // Stop on recursion. 81 | if (stack.has(value)) return; 82 | 83 | stack.add(value); 84 | const result = valueToString(value, space, onNext, key); 85 | stack.delete(value); 86 | return result; 87 | }; 88 | 89 | const result = onNext(value, ROOT_SENTINEL); 90 | 91 | // Attempt to restore circular references. 92 | if (unpack.size) { 93 | const sp = space ? " " : ""; 94 | const eol = space ? "\n" : ""; 95 | let wrapper = `var x${sp}=${sp}${result};${eol}`; 96 | 97 | for (const [key, value] of unpack.entries()) { 98 | const keyPath = stringifyPath(key, onNext); 99 | const valuePath = stringifyPath(value, onNext); 100 | 101 | wrapper += `x${keyPath}${sp}=${sp}x${valuePath};${eol}`; 102 | } 103 | 104 | return `(function${sp}()${sp}{${eol}${wrapper}return x;${eol}}())`; 105 | } 106 | 107 | return result; 108 | } 109 | 110 | /** 111 | * Create `toString()` function from replacer. 112 | */ 113 | function replacerToString(replacer?: ToString | null): ToString { 114 | if (!replacer) return toString; 115 | 116 | return (value, space, next, key) => { 117 | return replacer( 118 | value, 119 | space, 120 | (value: any) => toString(value, space, next, key), 121 | key 122 | ); 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JavaScript Stringify 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM downloads][downloads-image]][downloads-url] 5 | [![Build status][build-image]][build-url] 6 | [![Build coverage][coverage-image]][coverage-url] 7 | 8 | > Stringify is to `eval` as `JSON.stringify` is to `JSON.parse`. 9 | 10 | ## Installation 11 | 12 | ``` 13 | npm install javascript-stringify --save 14 | ``` 15 | 16 | ## Usage 17 | 18 | ```javascript 19 | import { stringify } from "javascript-stringify"; 20 | ``` 21 | 22 | The API is similar `JSON.stringify`: 23 | 24 | - `value` The value to convert to a string 25 | - `replacer` A function that alters the behavior of the stringification process 26 | - `space` A string or number that's used to insert white space into the output for readability purposes 27 | - `options` 28 | - **maxDepth** _(number, default: 100)_ The maximum depth of values to stringify 29 | - **maxValues** _(number, default: 100000)_ The maximum number of values to stringify 30 | - **references** _(boolean, default: false)_ Restore circular/repeated references in the object (uses IIFE) 31 | - **skipUndefinedProperties** _(boolean, default: false)_ Omits `undefined` properties instead of restoring as `undefined` 32 | 33 | ### Examples 34 | 35 | ```javascript 36 | stringify({}); // "{}" 37 | stringify(true); // "true" 38 | stringify("foo"); // "'foo'" 39 | 40 | stringify({ x: 5, y: 6 }); // "{x:5,y:6}" 41 | stringify([1, 2, 3, "string"]); // "[1,2,3,'string']" 42 | 43 | stringify({ a: { b: { c: 1 } } }, null, null, { maxDepth: 2 }); // "{a:{b:{}}}" 44 | 45 | /** 46 | * Invalid key names are automatically stringified. 47 | */ 48 | stringify({ "some-key": 10 }); // "{'some-key':10}" 49 | 50 | /** 51 | * Some object types and values can remain identical. 52 | */ 53 | stringify([/.+/gi, new Number(10), new Date()]); // "[/.+/gi,new Number(10),new Date(1406623295732)]" 54 | 55 | /** 56 | * Unknown or circular references are removed. 57 | */ 58 | var obj = { x: 10 }; 59 | obj.circular = obj; 60 | 61 | stringify(obj); // "{x:10}" 62 | stringify(obj, null, null, { references: true }); // "(function(){var x={x:10};x.circular=x;return x;}())" 63 | 64 | /** 65 | * Specify indentation - just like `JSON.stringify`. 66 | */ 67 | stringify({ a: 2 }, null, " "); // "{\n a: 2\n}" 68 | stringify({ uno: 1, dos: 2 }, null, "\t"); // "{\n\tuno: 1,\n\tdos: 2\n}" 69 | 70 | /** 71 | * Add custom replacer behaviour - like double quoted strings. 72 | */ 73 | stringify(["test", "string"], function (value, indent, stringify) { 74 | if (typeof value === "string") { 75 | return '"' + value.replace(/"/g, '\\"') + '"'; 76 | } 77 | 78 | return stringify(value); 79 | }); 80 | //=> '["test","string"]' 81 | ``` 82 | 83 | ## Formatting 84 | 85 | You can use your own code formatter on the result of `javascript-stringify`. Here is an example using [eslint](https://www.npmjs.com/package/eslint): 86 | 87 | ```javascript 88 | const { CLIEngine } = require("eslint"); 89 | const { stringify } = require("javascript-stringify"); 90 | 91 | const { APP_ROOT_PATH, ESLINTRC_FILE_PATH } = require("./constants"); 92 | 93 | const ESLINT_CLI = new CLIEngine({ 94 | fix: true, 95 | cwd: APP_ROOT_PATH, 96 | configFile: ESLINTRC_FILE_PATH, 97 | }); 98 | 99 | module.exports = (objectToStringify) => { 100 | return ESLINT_CLI.executeOnText(stringify(objectToStringify)).results[0] 101 | .output; 102 | }; 103 | ``` 104 | 105 | ## License 106 | 107 | MIT 108 | 109 | [npm-image]: https://img.shields.io/npm/v/javascript-stringify 110 | [npm-url]: https://npmjs.org/package/javascript-stringify 111 | [downloads-image]: https://img.shields.io/npm/dm/javascript-stringify 112 | [downloads-url]: https://npmjs.org/package/javascript-stringify 113 | [build-image]: https://img.shields.io/github/workflow/status/blakeembrey/javascript-stringify/CI/main 114 | [build-url]: https://github.com/blakeembrey/javascript-stringify/actions/workflows/ci.yml?query=branch%3Amain 115 | [coverage-image]: https://img.shields.io/codecov/c/gh/blakeembrey/javascript-stringify 116 | [coverage-url]: https://codecov.io/gh/blakeembrey/javascript-stringify 117 | -------------------------------------------------------------------------------- /src/function.ts: -------------------------------------------------------------------------------- 1 | import { Next, ToString } from "./types"; 2 | import { quoteKey, isValidVariableName } from "./quote"; 3 | 4 | /** 5 | * Used in function stringification. 6 | */ 7 | /* istanbul ignore next */ 8 | const METHOD_NAMES_ARE_QUOTED = 9 | { 10 | " "() { 11 | /* Empty. */ 12 | }, 13 | }[" "] 14 | .toString() 15 | .charAt(0) === '"'; 16 | 17 | const FUNCTION_PREFIXES = { 18 | Function: "function ", 19 | GeneratorFunction: "function* ", 20 | AsyncFunction: "async function ", 21 | AsyncGeneratorFunction: "async function* ", 22 | }; 23 | 24 | const METHOD_PREFIXES = { 25 | Function: "", 26 | GeneratorFunction: "*", 27 | AsyncFunction: "async ", 28 | AsyncGeneratorFunction: "async *", 29 | }; 30 | 31 | const TOKENS_PRECEDING_REGEXPS = new Set( 32 | ( 33 | "case delete else in instanceof new return throw typeof void " + 34 | ", ; : + - ! ~ & | ^ * / % < > ? =" 35 | ).split(" ") 36 | ); 37 | 38 | /** 39 | * Track function parser usage. 40 | */ 41 | export const USED_METHOD_KEY = new WeakSet<(...args: unknown[]) => unknown>(); 42 | 43 | /** 44 | * Stringify a function. 45 | */ 46 | export const functionToString: ToString = (fn, space, next, key) => { 47 | const name = typeof key === "string" ? key : undefined; 48 | 49 | // Track in function parser for object stringify to avoid duplicate output. 50 | if (name !== undefined) USED_METHOD_KEY.add(fn); 51 | 52 | return new FunctionParser(fn, space, next, name).stringify(); 53 | }; 54 | 55 | /** 56 | * Rewrite a stringified function to remove initial indentation. 57 | */ 58 | export function dedentFunction(fnString: string) { 59 | let found: string | undefined; 60 | 61 | for (const line of fnString.split("\n").slice(1)) { 62 | const m = /^[\s\t]+/.exec(line); 63 | if (!m) return fnString; // Early exit without indent. 64 | 65 | const [str] = m; 66 | 67 | if (found === undefined) found = str; 68 | else if (str.length < found.length) found = str; 69 | } 70 | 71 | return found ? fnString.split(`\n${found}`).join("\n") : fnString; 72 | } 73 | 74 | /** 75 | * Function parser and stringify. 76 | */ 77 | export class FunctionParser { 78 | fnString: string; 79 | fnType: keyof typeof FUNCTION_PREFIXES; 80 | keyQuote: string | undefined; 81 | keyPrefix: string; 82 | isMethodCandidate: boolean; 83 | 84 | pos = 0; 85 | hadKeyword = false; 86 | 87 | constructor( 88 | public fn: (...args: unknown[]) => unknown, 89 | public indent: string, 90 | public next: Next, 91 | public key?: string 92 | ) { 93 | this.fnString = Function.prototype.toString.call(fn); 94 | this.fnType = fn.constructor.name as keyof typeof FUNCTION_PREFIXES; 95 | this.keyQuote = key === undefined ? "" : quoteKey(key, next); 96 | this.keyPrefix = 97 | key === undefined ? "" : `${this.keyQuote}:${indent ? " " : ""}`; 98 | this.isMethodCandidate = 99 | key === undefined ? false : this.fn.name === "" || this.fn.name === key; 100 | } 101 | 102 | stringify() { 103 | const value = this.tryParse(); 104 | 105 | // If we can't stringify this function, return a void expression; for 106 | // bonus help with debugging, include the function as a string literal. 107 | if (!value) { 108 | return `${this.keyPrefix}void ${this.next(this.fnString)}`; 109 | } 110 | 111 | return dedentFunction(value); 112 | } 113 | 114 | getPrefix() { 115 | if (this.isMethodCandidate && !this.hadKeyword) { 116 | return METHOD_PREFIXES[this.fnType] + this.keyQuote; 117 | } 118 | 119 | return this.keyPrefix + FUNCTION_PREFIXES[this.fnType]; 120 | } 121 | 122 | tryParse() { 123 | if (this.fnString[this.fnString.length - 1] !== "}") { 124 | // Must be an arrow function. 125 | return this.keyPrefix + this.fnString; 126 | } 127 | 128 | // Attempt to remove function prefix. 129 | if (this.fn.name) { 130 | const result = this.tryStrippingName(); 131 | if (result) return result; 132 | } 133 | 134 | // Support class expressions. 135 | const prevPos = this.pos; 136 | if (this.consumeSyntax() === "class") return this.fnString; 137 | this.pos = prevPos; 138 | 139 | if (this.tryParsePrefixTokens()) { 140 | const result = this.tryStrippingName(); 141 | if (result) return result; 142 | 143 | let offset = this.pos; 144 | 145 | switch (this.consumeSyntax("WORD_LIKE")) { 146 | case "WORD_LIKE": 147 | if (this.isMethodCandidate && !this.hadKeyword) { 148 | offset = this.pos; 149 | } 150 | case "()": 151 | if (this.fnString.substr(this.pos, 2) === "=>") { 152 | return this.keyPrefix + this.fnString; 153 | } 154 | 155 | this.pos = offset; 156 | case '"': 157 | case "'": 158 | case "[]": 159 | return this.getPrefix() + this.fnString.substr(this.pos); 160 | } 161 | } 162 | } 163 | 164 | /** 165 | * Attempt to parse the function from the current position by first stripping 166 | * the function's name from the front. This is not a fool-proof method on all 167 | * JavaScript engines, but yields good results on Node.js 4 (and slightly 168 | * less good results on Node.js 6 and 8). 169 | */ 170 | tryStrippingName() { 171 | if (METHOD_NAMES_ARE_QUOTED) { 172 | // ... then this approach is unnecessary and yields false positives. 173 | return; 174 | } 175 | 176 | let start = this.pos; 177 | const prefix = this.fnString.substr(this.pos, this.fn.name.length); 178 | 179 | if (prefix === this.fn.name) { 180 | this.pos += prefix.length; 181 | 182 | if ( 183 | this.consumeSyntax() === "()" && 184 | this.consumeSyntax() === "{}" && 185 | this.pos === this.fnString.length 186 | ) { 187 | // Don't include the function's name if it will be included in the 188 | // prefix, or if it's invalid as a name in a function expression. 189 | if (this.isMethodCandidate || !isValidVariableName(prefix)) { 190 | start += prefix.length; 191 | } 192 | 193 | return this.getPrefix() + this.fnString.substr(start); 194 | } 195 | } 196 | 197 | this.pos = start; 198 | } 199 | 200 | /** 201 | * Attempt to advance the parser past the keywords expected to be at the 202 | * start of this function's definition. This method sets `this.hadKeyword` 203 | * based on whether or not a `function` keyword is consumed. 204 | */ 205 | tryParsePrefixTokens(): boolean { 206 | let posPrev = this.pos; 207 | 208 | this.hadKeyword = false; 209 | 210 | switch (this.fnType) { 211 | case "AsyncFunction": 212 | if (this.consumeSyntax() !== "async") return false; 213 | 214 | posPrev = this.pos; 215 | case "Function": 216 | if (this.consumeSyntax() === "function") { 217 | this.hadKeyword = true; 218 | } else { 219 | this.pos = posPrev; 220 | } 221 | return true; 222 | case "AsyncGeneratorFunction": 223 | if (this.consumeSyntax() !== "async") return false; 224 | case "GeneratorFunction": 225 | let token = this.consumeSyntax(); 226 | 227 | if (token === "function") { 228 | token = this.consumeSyntax(); 229 | this.hadKeyword = true; 230 | } 231 | 232 | return token === "*"; 233 | } 234 | } 235 | 236 | /** 237 | * Advance the parser past one element of JavaScript syntax. This could be a 238 | * matched pair of delimiters, like braces or parentheses, or an atomic unit 239 | * like a keyword, variable, or operator. Return a normalized string 240 | * representation of the element parsed--for example, returns '{}' for a 241 | * matched pair of braces. Comments and whitespace are skipped. 242 | * 243 | * (This isn't a full parser, so the token scanning logic used here is as 244 | * simple as it can be. As a consequence, some things that are one token in 245 | * JavaScript, like decimal number literals or most multi-character operators 246 | * like '&&', are split into more than one token here. However, awareness of 247 | * some multi-character sequences like '=>' is necessary, so we match the few 248 | * of them that we care about.) 249 | */ 250 | consumeSyntax(wordLikeToken?: string) { 251 | const m = this.consumeMatch( 252 | /^(?:([A-Za-z_0-9$\xA0-\uFFFF]+)|=>|\+\+|\-\-|.)/ 253 | ); 254 | 255 | if (!m) return; 256 | 257 | const [token, match] = m; 258 | this.consumeWhitespace(); 259 | 260 | if (match) return wordLikeToken || match; 261 | 262 | switch (token) { 263 | case "(": 264 | return this.consumeSyntaxUntil("(", ")"); 265 | case "[": 266 | return this.consumeSyntaxUntil("[", "]"); 267 | case "{": 268 | return this.consumeSyntaxUntil("{", "}"); 269 | case "`": 270 | return this.consumeTemplate(); 271 | case '"': 272 | return this.consumeRegExp(/^(?:[^\\"]|\\.)*"/, '"'); 273 | case "'": 274 | return this.consumeRegExp(/^(?:[^\\']|\\.)*'/, "'"); 275 | } 276 | 277 | return token; 278 | } 279 | 280 | consumeSyntaxUntil(startToken: string, endToken: string): string | undefined { 281 | let isRegExpAllowed = true; 282 | 283 | for (;;) { 284 | const token = this.consumeSyntax(); 285 | if (token === endToken) return startToken + endToken; 286 | if (!token || token === ")" || token === "]" || token === "}") return; 287 | 288 | if ( 289 | token === "/" && 290 | isRegExpAllowed && 291 | this.consumeMatch(/^(?:\\.|[^\\\/\n[]|\[(?:\\.|[^\]])*\])+\/[a-z]*/) 292 | ) { 293 | isRegExpAllowed = false; 294 | this.consumeWhitespace(); 295 | } else { 296 | isRegExpAllowed = TOKENS_PRECEDING_REGEXPS.has(token); 297 | } 298 | } 299 | } 300 | 301 | consumeMatch(re: RegExp) { 302 | const m = re.exec(this.fnString.substr(this.pos)); 303 | if (m) this.pos += m[0].length; 304 | return m; 305 | } 306 | 307 | /** 308 | * Advance the parser past an arbitrary regular expression. Return `token`, 309 | * or the match object of the regexp. 310 | */ 311 | consumeRegExp(re: RegExp, token: string): string | undefined { 312 | const m = re.exec(this.fnString.substr(this.pos)); 313 | if (!m) return; 314 | this.pos += m[0].length; 315 | this.consumeWhitespace(); 316 | return token; 317 | } 318 | 319 | /** 320 | * Advance the parser past a template string. 321 | */ 322 | consumeTemplate() { 323 | for (;;) { 324 | this.consumeMatch(/^(?:[^`$\\]|\\.|\$(?!{))*/); 325 | 326 | if (this.fnString[this.pos] === "`") { 327 | this.pos++; 328 | this.consumeWhitespace(); 329 | return "`"; 330 | } 331 | 332 | if (this.fnString.substr(this.pos, 2) === "${") { 333 | this.pos += 2; 334 | this.consumeWhitespace(); 335 | 336 | if (this.consumeSyntaxUntil("{", "}")) continue; 337 | } 338 | 339 | return; 340 | } 341 | } 342 | 343 | /** 344 | * Advance the parser past any whitespace or comments. 345 | */ 346 | consumeWhitespace() { 347 | this.consumeMatch(/^(?:\s|\/\/.*|\/\*[^]*?\*\/)*/); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { satisfies } from "semver"; 3 | import { stringify, Options } from "./index"; 4 | 5 | // Declare global window type. 6 | declare const window: any; 7 | 8 | // Evaluate a string into JavaScript 9 | const evalValue = (str: string | undefined) => { 10 | return eval(`(${str})`); 11 | }; 12 | 13 | /** 14 | * Create a quick test function wrapper. 15 | */ 16 | function test( 17 | value: any, 18 | result: string, 19 | indent?: string | number | null, 20 | options?: Options 21 | ) { 22 | return () => { 23 | expect(stringify(value, null, indent, options)).toEqual(result); 24 | }; 25 | } 26 | 27 | /** 28 | * Create a wrapper for round-trip eval tests. 29 | */ 30 | function testRoundTrip( 31 | expression: string, 32 | indent?: string | number, 33 | options?: Options 34 | ) { 35 | return () => test(evalValue(expression), expression, indent, options)(); 36 | } 37 | 38 | /** 39 | * Check if syntax is supported. 40 | */ 41 | function isSupported(expr: string) { 42 | try { 43 | eval(expr); 44 | return true; 45 | } catch (err) { 46 | if (err.name === "SyntaxError") return false; 47 | throw err; 48 | } 49 | } 50 | 51 | /** 52 | * Generate a list of test cases to run. 53 | */ 54 | function cases(cases: (string | undefined)[]) { 55 | return () => { 56 | for (const value of cases) { 57 | if (value) it(value, testRoundTrip(value)); 58 | } 59 | }; 60 | } 61 | 62 | /** 63 | * Conditionally execute test cases. 64 | */ 65 | function describeIf( 66 | description: string, 67 | condition: boolean, 68 | fn: jest.EmptyFunction 69 | ) { 70 | return condition ? describe(description, fn) : describe.skip(description, fn); 71 | } 72 | 73 | describe("javascript-stringify", () => { 74 | describe("types", () => { 75 | describe("booleans", () => { 76 | it("should be stringified", test(true, "true")); 77 | }); 78 | 79 | describe("strings", () => { 80 | it("should wrap in single quotes", test("string", "'string'")); 81 | 82 | it("should escape quote characters", test("'test'", "'\\'test\\''")); 83 | 84 | it( 85 | "should escape control characters", 86 | test("multi\nline", "'multi\\nline'") 87 | ); 88 | 89 | it("should escape back slashes", test("back\\slash", "'back\\\\slash'")); 90 | 91 | it( 92 | "should escape certain unicode sequences", 93 | test("\u0602", "'\\u0602'") 94 | ); 95 | }); 96 | 97 | describe("numbers", () => { 98 | it("should stringify integers", test(10, "10")); 99 | 100 | it("should stringify floats", test(10.5, "10.5")); 101 | 102 | it('should stringify "NaN"', test(10.5, "10.5")); 103 | 104 | it('should stringify "Infinity"', test(Infinity, "Infinity")); 105 | 106 | it('should stringify "-Infinity"', test(-Infinity, "-Infinity")); 107 | 108 | it('should stringify "-0"', test(-0, "-0")); 109 | }); 110 | 111 | describe("arrays", () => { 112 | it("should stringify as array shorthand", test([1, 2, 3], "[1,2,3]")); 113 | 114 | it( 115 | "should indent elements", 116 | test([{ x: 10 }], "[\n\t{\n\t\tx: 10\n\t}\n]", "\t") 117 | ); 118 | }); 119 | 120 | describe("objects", () => { 121 | it( 122 | "should stringify as object shorthand", 123 | test({ key: "value", "-": 10 }, "{key:'value','-':10}") 124 | ); 125 | 126 | it( 127 | "should stringify undefined keys", 128 | test({ a: true, b: undefined }, "{a:true,b:undefined}") 129 | ); 130 | 131 | it( 132 | "should stringify omit undefined keys", 133 | test({ a: true, b: undefined }, "{a:true}", null, { 134 | skipUndefinedProperties: true, 135 | }) 136 | ); 137 | 138 | it( 139 | "should quote reserved word keys", 140 | test({ if: true, else: false }, "{'if':true,'else':false}") 141 | ); 142 | 143 | it( 144 | "should not quote Object.prototype keys", 145 | test({ constructor: 1, toString: 2 }, "{constructor:1,toString:2}") 146 | ); 147 | }); 148 | 149 | describe("functions", () => { 150 | it( 151 | "should reindent function bodies", 152 | test( 153 | evalValue( 154 | `function() { 155 | if (true) { 156 | return "hello"; 157 | } 158 | }` 159 | ), 160 | 'function () {\n if (true) {\n return "hello";\n }\n}', 161 | 2 162 | ) 163 | ); 164 | 165 | it( 166 | "should reindent function bodies in objects", 167 | test( 168 | evalValue(` 169 | { 170 | fn: function() { 171 | if (true) { 172 | return "hello"; 173 | } 174 | } 175 | } 176 | `), 177 | '{\n fn: function () {\n if (true) {\n return "hello";\n }\n }\n}', 178 | 2 179 | ) 180 | ); 181 | 182 | it( 183 | "should reindent function bodies in arrays", 184 | test( 185 | evalValue(`[ 186 | function() { 187 | if (true) { 188 | return "hello"; 189 | } 190 | } 191 | ]`), 192 | '[\n function () {\n if (true) {\n return "hello";\n }\n }\n]', 193 | 2 194 | ) 195 | ); 196 | 197 | it( 198 | "should not need to reindent one-liners", 199 | testRoundTrip("{\n fn: function () { return; }\n}", 2) 200 | ); 201 | 202 | it("should gracefully handle unexpected Function.toString formats", () => { 203 | const origToString = Function.prototype.toString; 204 | 205 | Function.prototype.toString = () => "{nope}"; 206 | 207 | try { 208 | expect( 209 | stringify(function () { 210 | /* Empty */ 211 | }) 212 | ).toEqual("void '{nope}'"); 213 | } finally { 214 | Function.prototype.toString = origToString; 215 | } 216 | }); 217 | 218 | describe( 219 | "omit the names of their keys", 220 | cases(["{name:function () {}}", "{'tricky name':function () {}}"]) 221 | ); 222 | }); 223 | 224 | describe("native instances", () => { 225 | describe("Date", () => { 226 | const date = new Date(); 227 | 228 | it("should stringify", test(date, "new Date(" + date.getTime() + ")")); 229 | }); 230 | 231 | describe("RegExp", () => { 232 | it("should stringify as shorthand", test(/[abc]/gi, "/[abc]/gi")); 233 | }); 234 | 235 | describe("Number", () => { 236 | it("should stringify", test(new Number(10), "new Number(10)")); 237 | }); 238 | 239 | describe("String", () => { 240 | it("should stringify", test(new String("abc"), "new String('abc')")); 241 | }); 242 | 243 | describe("Boolean", () => { 244 | it("should stringify", test(new Boolean(true), "new Boolean(true)")); 245 | }); 246 | 247 | describeIf("Buffer", typeof (Buffer as any) === "function", () => { 248 | it( 249 | "should stringify", 250 | test(Buffer.from("test"), "Buffer.from('dGVzdA==', 'base64')") 251 | ); 252 | }); 253 | 254 | describeIf("BigInt", typeof (BigInt as any) === "function", () => { 255 | it("should stringify", test(BigInt("10"), "BigInt('10')")); 256 | }); 257 | 258 | describe("Error", () => { 259 | it("should stringify", test(new Error("test"), "new Error('test')")); 260 | }); 261 | 262 | describe("unknown native type", () => { 263 | it( 264 | "should be omitted", 265 | test( 266 | { 267 | k: 268 | typeof (process as any) === "undefined" 269 | ? window.navigator 270 | : process, 271 | }, 272 | "{}" 273 | ) 274 | ); 275 | }); 276 | }); 277 | 278 | describeIf("ES6", typeof (Array as any).from === "function", () => { 279 | describeIf("Map", typeof (Map as any) === "function", () => { 280 | it( 281 | "should stringify", 282 | test(new Map([["key", "value"]]), "new Map([['key','value']])") 283 | ); 284 | }); 285 | 286 | describeIf("Set", typeof (Set as any) === "function", () => { 287 | it( 288 | "should stringify", 289 | test(new Set(["key", "value"]), "new Set(['key','value'])") 290 | ); 291 | }); 292 | 293 | describe("arrow functions", () => { 294 | describe( 295 | "should stringify", 296 | cases([ 297 | "(a, b) => a + b", 298 | "o => { return o.a + o.b; }", 299 | "(a, b) => { if (a) { return b; } }", 300 | "(a, b) => ({ [a]: b })", 301 | "a => b => () => a + b", 302 | ]) 303 | ); 304 | 305 | it( 306 | "should reindent function bodies", 307 | test( 308 | evalValue( 309 | " () => {\n" + 310 | " if (true) {\n" + 311 | ' return "hello";\n' + 312 | " }\n" + 313 | " }" 314 | ), 315 | '() => {\n if (true) {\n return "hello";\n }\n}', 316 | 2 317 | ) 318 | ); 319 | 320 | describeIf("arrows with patterns", isSupported("({x}) => x"), () => { 321 | describe( 322 | "should stringify", 323 | cases([ 324 | "({ x, y }) => x + y", 325 | "({ x, y }) => { if (x === '}') { return y; } }", 326 | "({ x, y = /[/})]/.test(x) }) => { return y ? x : 0; }", 327 | ]) 328 | ); 329 | }); 330 | }); 331 | 332 | describe("generators", () => { 333 | it("should stringify", testRoundTrip("function* (x) { yield x; }")); 334 | }); 335 | 336 | describe("class notation", () => { 337 | it("should stringify classes", testRoundTrip("class {}")); 338 | it( 339 | "should stringify class and method", 340 | testRoundTrip("class { method() {} }") 341 | ); 342 | it( 343 | "should stringify with newline", 344 | testRoundTrip("class\n{ method() {} }") 345 | ); 346 | it( 347 | "should stringify with comment", 348 | testRoundTrip("class/*test*/\n{ method() {} }") 349 | ); 350 | }); 351 | 352 | describe("method notation", () => { 353 | it("should stringify", testRoundTrip("{a(b, c) { return b + c; }}")); 354 | 355 | it( 356 | "should stringify generator methods", 357 | testRoundTrip("{*a(b) { yield b; }}") 358 | ); 359 | 360 | describe( 361 | "should not be fooled by tricky names", 362 | cases([ 363 | "{'function a'(b, c) { return b + c; }}", 364 | "{'a(a'(b, c) { return b + c; }}", 365 | "{'() => function '() {}}", 366 | "{'['() { return x[y]()\n{ return true; }}}", 367 | "{'() { return false;//'() { return true;\n}}", 368 | ]) 369 | ); 370 | 371 | it( 372 | "should not be fooled by tricky generator names", 373 | testRoundTrip("{*'function a'(b, c) { return b + c; }}") 374 | ); 375 | 376 | it( 377 | "should not be fooled by empty names", 378 | testRoundTrip("{''(b, c) { return b + c; }}") 379 | ); 380 | 381 | it("should not be fooled by keys that look like functions", () => { 382 | const fn = evalValue('{ "() => ": () => () => 42 }')["() => "]; 383 | expect(stringify(fn)).toEqual("() => () => 42"); 384 | }); 385 | 386 | describe( 387 | "should not be fooled by arrow functions", 388 | cases([ 389 | "{a:(b, c) => b + c}", 390 | "{a:a => a + 1}", 391 | "{'() => ':() => () => 42}", 392 | '{\'() => "\':() => "() {//"}', 393 | '{\'() => "\':() => "() {`//"}', 394 | '{\'() => "\':() => "() {`${//"}', 395 | '{\'() => "\':() => "() {/*//"}', 396 | satisfies(process.versions.node, "<=4 || >=10") 397 | ? "{'a => function ':a => function () { return a + 1; }}" 398 | : undefined, 399 | ]) 400 | ); 401 | 402 | describe( 403 | "should not be fooled by regexp literals", 404 | cases([ 405 | "{' '(s) { return /}/.test(s); }}", 406 | "{' '(s) { return /abc/ .test(s); }}", 407 | "{' '() { return x / y; // /}\n}}", 408 | "{' '() { return / y; }//* } */}}", 409 | "{' '() { return delete / y; }/.x}}", 410 | "{' '() { switch (x) { case / y; }}/: }}}", 411 | "{' '() { if (x) return; else / y;}/; }}", 412 | "{' '() { return x in / y;}/; }}", 413 | "{' '() { return x instanceof / y;}/; }}", 414 | "{' '() { return new / y;}/.x; }}", 415 | "{' '() { throw / y;}/.x; }}", 416 | "{' '() { return typeof / y;}/; }}", 417 | "{' '() { void / y;}/; }}", 418 | "{' '() { return x, / y;}/; }}", 419 | "{' '() { return x; / y;}/; }}", 420 | "{' '() { return { x: / y;}/ }; }}", 421 | "{' '() { return x + / y;}/.x; }}", 422 | "{' '() { return x - / y;}/.x; }}", 423 | "{' '() { return !/ y;}/; }}", 424 | "{' '() { return ~/ y;}/.x; }}", 425 | "{' '() { return x && / y;}/; }}", 426 | "{' '() { return x || / y;}/; }}", 427 | "{' '() { return x ^ / y;}/.x; }}", 428 | "{' '() { return x * / y;}/.x; }}", 429 | "{' '() { return x / / y;}/.x; }}", 430 | "{' '() { return x % / y;}/.x; }}", 431 | "{' '() { return x < / y;}/.x; }}", 432 | "{' '() { return x > / y;}/.x; }}", 433 | "{' '() { return x <= / y;}/.x; }}", 434 | "{' '() { return x /= / y;}/.x; }}", 435 | "{' '() { return x ? / y;}/ : false; }}", 436 | ]) 437 | ); 438 | 439 | describe("should not be fooled by computed names", () => { 440 | it( 441 | "1", 442 | test( 443 | evalValue('{ ["foobar".slice(3)](x) { return x + 1; } }'), 444 | "{bar(x) { return x + 1; }}" 445 | ) 446 | ); 447 | 448 | it( 449 | "2", 450 | test( 451 | evalValue( 452 | '{[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\",\\"+s(b)+b)(JSON.stringify,",")]() {}")]() {}}' 453 | ), 454 | '{\'[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\\\",\\\\"+s(b)+b)(JSON.stringify,",")]() {}")]() {}\'() {}}' 455 | ) 456 | ); 457 | 458 | it( 459 | "3", 460 | test( 461 | evalValue( 462 | '{[`over${`6${"0".repeat(3)}`.replace("6", "9")}`]() { this.activateHair(); }}' 463 | ), 464 | "{over9000() { this.activateHair(); }}" 465 | ) 466 | ); 467 | 468 | it( 469 | "4", 470 | test(evalValue("{[\"() {'\"]() {''}}"), "{'() {\\''() {''}}") 471 | ); 472 | 473 | it("5", test(evalValue('{["() {`"]() {``}}'), "{'() {`'() {``}}")); 474 | 475 | it( 476 | "6", 477 | test( 478 | evalValue('{["() {/*"]() {/*`${()=>{/*}*/}}'), 479 | "{'() {/*'() {/*`${()=>{/*}*/}}" 480 | ) 481 | ); 482 | }); 483 | 484 | // These two cases demonstrate that branching on 485 | // METHOD_NAMES_ARE_QUOTED is unavoidable--you can't write code 486 | // without it that will pass both of these cases on both node.js 4 487 | // and node.js 10. (If you think you can, consider that the name and 488 | // toString of the first case when executed on node.js 10 are 489 | // identical to the name and toString of the second case when 490 | // executed on node.js 4, so good luck telling them apart without 491 | // knowing which node you're on.) 492 | describe("should handle different versions of node correctly", () => { 493 | it( 494 | "1", 495 | test( 496 | evalValue( 497 | '{[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\",\\"+s(b)+b)(JSON.stringify,",")]() { return 0; /*")]() { return 0; /*() {/* */ return 1;}}' 498 | ), 499 | '{\'[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\\\",\\\\"+s(b)+b)(JSON.stringify,",")]() { return 0; /*")]() { return 0; /*\'() { return 0; /*() {/* */ return 1;}}' 500 | ) 501 | ); 502 | 503 | it( 504 | "2", 505 | test( 506 | evalValue( 507 | '{\'[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\\\",\\\\"+s(b)+b)(JSON.stringify,",")]() { return 0; /*")]() { return 0; /*\'() {/* */ return 1;}}' 508 | ), 509 | '{\'[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\\\",\\\\"+s(b)+b)(JSON.stringify,",")]() { return 0; /*")]() { return 0; /*\'() {/* */ return 1;}}' 510 | ) 511 | ); 512 | }); 513 | 514 | it( 515 | "should not be fooled by comments", 516 | test( 517 | evalValue( 518 | "{'method' /* a comment! */ () /* another comment! */ {}}" 519 | ), 520 | "{method() /* another comment! */ {}}" 521 | ) 522 | ); 523 | 524 | it("should stringify extracted methods", () => { 525 | const fn = evalValue("{ foo(x) { return x + 1; } }").foo; 526 | expect(stringify(fn)).toEqual("function foo(x) { return x + 1; }"); 527 | }); 528 | 529 | it("should stringify extracted generators", () => { 530 | const fn = evalValue("{ *foo(x) { yield x; } }").foo; 531 | expect(stringify(fn)).toEqual("function* foo(x) { yield x; }"); 532 | }); 533 | 534 | it("should stringify extracted methods with tricky names", () => { 535 | const fn = evalValue('{ "a(a"(x) { return x + 1; } }')["a(a"]; 536 | expect(stringify(fn)).toEqual("function (x) { return x + 1; }"); 537 | }); 538 | 539 | it("should stringify extracted methods with arrow-like tricky names", () => { 540 | const fn = evalValue('{ "() => function "(x) { return x + 1; } }')[ 541 | "() => function " 542 | ]; 543 | expect(stringify(fn)).toEqual("function (x) { return x + 1; }"); 544 | }); 545 | 546 | it("should stringify extracted methods with empty names", () => { 547 | const fn = evalValue('{ ""(x) { return x + 1; } }')[""]; 548 | expect(stringify(fn)).toEqual("function (x) { return x + 1; }"); 549 | }); 550 | 551 | it("should handle transplanted names", () => { 552 | const fn = evalValue("{ foo(x) { return x + 1; } }").foo; 553 | 554 | expect(stringify({ bar: fn })).toEqual( 555 | "{bar:function foo(x) { return x + 1; }}" 556 | ); 557 | }); 558 | 559 | it("should handle transplanted names with generators", () => { 560 | const fn = evalValue("{ *foo(x) { yield x; } }").foo; 561 | 562 | expect(stringify({ bar: fn })).toEqual( 563 | "{bar:function* foo(x) { yield x; }}" 564 | ); 565 | }); 566 | 567 | it( 568 | "should reindent methods", 569 | test( 570 | evalValue( 571 | " {\n" + 572 | " fn() {\n" + 573 | " if (true) {\n" + 574 | ' return "hello";\n' + 575 | " }\n" + 576 | " }\n" + 577 | " }" 578 | ), 579 | '{\n fn() {\n if (true) {\n return "hello";\n }\n }\n}', 580 | 2 581 | ) 582 | ); 583 | }); 584 | }); 585 | 586 | describe("ES2017", () => { 587 | describeIf( 588 | "async functions", 589 | isSupported("(async function () {})"), 590 | () => { 591 | it( 592 | "should stringify", 593 | testRoundTrip("async function (x) { await x; }") 594 | ); 595 | 596 | it("should gracefully handle unexpected Function.toString formats", () => { 597 | const origToString = Function.prototype.toString; 598 | 599 | Function.prototype.toString = () => "{nope}"; 600 | 601 | try { 602 | expect(stringify(evalValue("async function () {}"))).toEqual( 603 | "void '{nope}'" 604 | ); 605 | } finally { 606 | Function.prototype.toString = origToString; 607 | } 608 | }); 609 | } 610 | ); 611 | 612 | describeIf("async arrows", isSupported("async () => {}"), () => { 613 | describe( 614 | "should stringify", 615 | cases([ 616 | "async (x) => x + 1", 617 | "async x => x + 1", 618 | "async x => { await x.then(y => y + 1); }", 619 | ]) 620 | ); 621 | 622 | describe( 623 | "should stringify as object properties", 624 | cases([ 625 | "{f:async a => a + 1}", 626 | satisfies(process.versions.node, "<=4 || >=10") 627 | ? "{'async a => function ':async a => function () { return a + 1; }}" 628 | : undefined, 629 | ]) 630 | ); 631 | }); 632 | }); 633 | 634 | describe("ES2018", () => { 635 | describeIf( 636 | "async generators", 637 | isSupported("(async function* () {})"), 638 | () => { 639 | it( 640 | "should stringify", 641 | testRoundTrip("async function* (x) { yield x; }") 642 | ); 643 | 644 | it("should gracefully handle unexpected Function.toString formats", () => { 645 | const origToString = Function.prototype.toString; 646 | 647 | Function.prototype.toString = () => "{nope}"; 648 | 649 | try { 650 | expect(stringify(evalValue("async function* () {}"))).toEqual( 651 | "void '{nope}'" 652 | ); 653 | } finally { 654 | Function.prototype.toString = origToString; 655 | } 656 | }); 657 | } 658 | ); 659 | }); 660 | 661 | describe("global", () => { 662 | it( 663 | "should access the global in the current environment", 664 | testRoundTrip("Function('return this')()") 665 | ); 666 | }); 667 | }); 668 | 669 | describe("circular references", () => { 670 | it("should omit circular references", () => { 671 | const obj: any = { key: "value" }; 672 | obj.obj = obj; 673 | 674 | const result = stringify(obj); 675 | 676 | expect(result).toEqual("{key:'value'}"); 677 | }); 678 | 679 | it("should restore value", () => { 680 | const obj: any = { key: "value" }; 681 | obj.obj = obj; 682 | 683 | const result = stringify(obj, null, null, { references: true }); 684 | 685 | expect(result).toEqual( 686 | "(function(){var x={key:'value',obj:undefined};x.obj=x;return x;}())" 687 | ); 688 | }); 689 | 690 | it("should omit recursive array value", () => { 691 | const obj: any = [1, 2, 3]; 692 | obj.push(obj); 693 | 694 | const result = stringify(obj); 695 | 696 | expect(result).toEqual("[1,2,3,undefined]"); 697 | }); 698 | 699 | it("should restore array value", () => { 700 | const obj: any = [1, 2, 3]; 701 | obj.push(obj); 702 | 703 | const result = stringify(obj, null, null, { references: true }); 704 | 705 | expect(result).toEqual( 706 | "(function(){var x=[1,2,3,undefined];x[3]=x;return x;}())" 707 | ); 708 | }); 709 | 710 | it("should print repeated values when no references enabled", () => { 711 | const obj: any = {}; 712 | const child = {}; 713 | 714 | obj.a = child; 715 | obj.b = child; 716 | 717 | const result = stringify(obj); 718 | 719 | expect(result).toEqual("{a:{},b:{}}"); 720 | }); 721 | 722 | it("should restore repeated values", () => { 723 | const obj: any = {}; 724 | const child = {}; 725 | 726 | obj.a = child; 727 | obj.b = child; 728 | 729 | const result = stringify(obj, null, null, { references: true }); 730 | 731 | expect(result).toEqual( 732 | "(function(){var x={a:{},b:undefined};x.b=x.a;return x;}())" 733 | ); 734 | }); 735 | 736 | it("should restore repeated values with indentation", function () { 737 | const obj: any = {}; 738 | const child = {}; 739 | 740 | obj.a = child; 741 | obj.b = child; 742 | 743 | const result = stringify(obj, null, 2, { references: true }); 744 | 745 | expect(result).toEqual( 746 | "(function () {\nvar x = {\n a: {},\n b: undefined\n};\nx.b = x.a;\nreturn x;\n}())" 747 | ); 748 | }); 749 | 750 | it("should maintain key order when restoring repeated values", () => { 751 | const obj: any = {}; 752 | const child = {}; 753 | 754 | obj.a = child; 755 | obj.b = child; 756 | obj.c = "C"; 757 | 758 | const result = stringify(obj, null, null, { references: true }); 759 | 760 | expect(result).toEqual( 761 | "(function(){var x={a:{},b:undefined,c:'C'};x.b=x.a;return x;}())" 762 | ); 763 | }); 764 | }); 765 | 766 | describe("custom indent", () => { 767 | it("string", () => { 768 | const result = stringify( 769 | { 770 | test: [1, 2, 3], 771 | nested: { 772 | key: "value", 773 | }, 774 | }, 775 | null, 776 | "\t" 777 | ); 778 | 779 | expect(result).toEqual( 780 | "{\n" + 781 | "\ttest: [\n\t\t1,\n\t\t2,\n\t\t3\n\t],\n" + 782 | "\tnested: {\n\t\tkey: 'value'\n\t}\n" + 783 | "}" 784 | ); 785 | }); 786 | 787 | it("integer", () => { 788 | const result = stringify( 789 | { 790 | test: [1, 2, 3], 791 | nested: { 792 | key: "value", 793 | }, 794 | }, 795 | null, 796 | 2 797 | ); 798 | 799 | expect(result).toEqual( 800 | "{\n" + 801 | " test: [\n 1,\n 2,\n 3\n ],\n" + 802 | " nested: {\n key: 'value'\n }\n" + 803 | "}" 804 | ); 805 | }); 806 | 807 | it("float", () => { 808 | const result = stringify( 809 | { 810 | test: [1, 2, 3], 811 | nested: { 812 | key: "value", 813 | }, 814 | }, 815 | null, 816 | 2.6 817 | ); 818 | 819 | expect(result).toEqual( 820 | "{\n" + 821 | " test: [\n 1,\n 2,\n 3\n ],\n" + 822 | " nested: {\n key: 'value'\n }\n" + 823 | "}" 824 | ); 825 | }); 826 | }); 827 | 828 | describe("replacer function", () => { 829 | it("should allow custom replacements", () => { 830 | let callCount = 0; 831 | 832 | const result = stringify( 833 | { 834 | test: "value", 835 | }, 836 | function (value, indent, next) { 837 | callCount++; 838 | 839 | if (typeof value === "string") { 840 | return '"hello"'; 841 | } 842 | 843 | return next(value); 844 | } 845 | ); 846 | 847 | expect(callCount).toEqual(2); 848 | expect(result).toEqual('{test:"hello"}'); 849 | }); 850 | 851 | it("change primitive to object", () => { 852 | const result = stringify( 853 | { 854 | test: 10, 855 | }, 856 | function (value, indent, next) { 857 | if (typeof value === "number") { 858 | return next({ obj: "value" }); 859 | } 860 | 861 | return next(value); 862 | } 863 | ); 864 | 865 | expect(result).toEqual("{test:{obj:'value'}}"); 866 | }); 867 | 868 | it("change object to primitive", () => { 869 | const result = stringify( 870 | { 871 | test: 10, 872 | }, 873 | (value) => Object.prototype.toString.call(value) 874 | ); 875 | 876 | expect(result).toEqual("[object Object]"); 877 | }); 878 | 879 | it("should support object functions", () => { 880 | function makeRaw(str: string) { 881 | const fn = () => { 882 | /* Noop. */ 883 | }; 884 | fn.__expression = str; 885 | return fn; 886 | } 887 | 888 | const result = stringify( 889 | { 890 | "no-console": makeRaw( 891 | `process.env.NODE_ENV === 'production' ? 'error' : 'off'` 892 | ), 893 | "no-debugger": makeRaw( 894 | `process.env.NODE_ENV === 'production' ? 'error' : 'off'` 895 | ), 896 | }, 897 | (val, indent, stringify) => { 898 | if (val && val.__expression) { 899 | return val.__expression; 900 | } 901 | return stringify(val); 902 | }, 903 | 2 904 | ); 905 | 906 | expect(result).toEqual(`{ 907 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 908 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 909 | }`); 910 | }); 911 | }); 912 | 913 | describe("max depth", () => { 914 | const obj = { a: { b: { c: 1 } } }; 915 | 916 | it("should get all object", test(obj, "{a:{b:{c:1}}}")); 917 | 918 | it( 919 | "should get part of the object", 920 | test(obj, "{a:{b:{}}}", null, { maxDepth: 2 }) 921 | ); 922 | 923 | it( 924 | "should get part of the object when tracking references", 925 | test(obj, "{a:{b:{}}}", null, { maxDepth: 2, references: true }) 926 | ); 927 | }); 928 | 929 | describe("property based", () => { 930 | it("should produce string evaluating to the original value", () => { 931 | fc.assert( 932 | fc.property(fc.anything(), (value) => { 933 | expect(evalValue(stringify(value))).toEqual(value); 934 | }) 935 | ); 936 | }); 937 | }); 938 | }); 939 | --------------------------------------------------------------------------------