From 973afa36ddb7f9f7338fe063448b52b6d37d8004 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Fri, 8 Feb 2019 18:11:57 -0500 Subject: [PATCH 1/2] Update `package-lock.json` --- package-lock.json | 80 +++++++++++++++++------------------------------ 1 file changed, 28 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9844cb8..eaab686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,9 +55,9 @@ } }, "browser-stdout": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", - "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, "chai": { @@ -81,9 +81,9 @@ "dev": true }, "commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", "dev": true }, "concat-map": { @@ -117,9 +117,9 @@ "dev": true }, "diff": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.3.1.tgz", - "integrity": "sha512-MKPHZDMB0o6yHyDryUOScqZibp914ksXwAMYMTHj6KO8UeKsRYNJD3oNCKjTqZon+V488P7N/HzXF8t7ZR95ww==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true }, "escape-string-regexp": { @@ -201,9 +201,9 @@ } }, "growl": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", - "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, "handlebars": { @@ -375,39 +375,24 @@ } }, "mocha": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-4.0.1.tgz", - "integrity": "sha512-evDmhkoA+cBNiQQQdSKZa2b9+W2mpLoj50367lhy+Klnx9OV8XlCIhigUnn1gaTFLQCa0kdNhEGDr0hCXOQFDw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", "dev": true, "requires": { - "browser-stdout": "1.3.0", - "commander": "2.11.0", + "browser-stdout": "1.3.1", + "commander": "2.15.1", "debug": "3.1.0", - "diff": "3.3.1", + "diff": "3.5.0", "escape-string-regexp": "1.0.5", "glob": "7.1.2", - "growl": "1.10.3", + "growl": "1.10.5", "he": "1.1.1", + "minimatch": "3.0.4", "mkdirp": "0.5.1", - "supports-color": "4.4.0" + "supports-color": "5.4.0" }, "dependencies": { - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "brace-expansion": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", - "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", @@ -423,27 +408,18 @@ } }, "has-flag": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", - "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, "supports-color": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", - "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", "dev": true, "requires": { - "has-flag": "^2.0.0" + "has-flag": "^3.0.0" } } } From ccb5221830e70cd05672769bd676d3462eaf215e Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Fri, 8 Feb 2019 18:13:09 -0500 Subject: [PATCH 2/2] Extensive rewrite of function stringification Node.js 10 changed how the toString method on functions works. The resulting string is much less ambiguous than what the method produces on older versions, but also requires a more robust parser to interpret correctly. Stripping off computed property prefixes necessitates the ability to find the matching ']' to a given '[', and doing that correctly means taking into account all the ways that comments, template strings, and regular expression literals can fool a naive implementation. A full JavaScript parser would be many times the amount of code added here; this "parser-lite" implementation cuts many corners but is possibly bug-free on the most recent version of Node. The toString implementation of previous versions of Node continues to be supported. On Node.js 4, this logic is also possibly bug-free. However, a behavior change that I believe started in Node.js 6 means that versions of Node.js greater than 4 and less than 10 can toString a small subset of functions in such a way that it is impossible to unambiguously stringify them in all circumstances. Still, we do the best we can. Tests that are conditionally skipped on the affected Node.js versions demonstrate the problem. --- .travis.yml | 1 + javascript-stringify.js | 343 +++++++++++++++++++++++++++++++++------- test.js | 256 +++++++++++++++++++++++++++--- 3 files changed, 522 insertions(+), 78 deletions(-) diff --git a/.travis.yml b/.travis.yml index ab8f2cb..5bf2393 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ notifications: node_js: - "4" + - "8" - "stable" after_script: "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls" diff --git a/javascript-stringify.js b/javascript-stringify.js index f888eee..e76fc08 100644 --- a/javascript-stringify.js +++ b/javascript-stringify.js @@ -71,6 +71,12 @@ */ var IS_VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + /** + * Used in function stringification. + */ + /* istanbul ignore next */ + var METHOD_NAMES_ARE_QUOTED = ({' '(){}})[' '].toString()[0] === "'"; + /** * Check if a variable name is valid. * @@ -81,27 +87,6 @@ return !RESERVED_WORDS.hasOwnProperty(name) && IS_VALID_IDENTIFIER.test(name); } - /** - * Check if a function is an ES6 generator function - * - * @param {Function} fn - * @return {boolean} - */ - function isGeneratorFunction (fn) { - return fn.constructor.name === 'GeneratorFunction'; - } - - /** - * Can be replaced with `str.startsWith(prefix)` if code is updated to ES6. - * - * @param {string} str - * @param {string} prefix - * @return {boolean} - */ - function stringStartsWith (str, prefix) { - return str.substring(0, prefix.length) === prefix; - } - /** * Can be replaced with `str.repeat(count)` if code is updated to ES6. * @@ -184,41 +169,18 @@ var value; var addKey = true; - // Handle functions specially to detect method notation. if (typeof object[key] === 'function') { - var fn = object[key]; - var fnString = fn.toString(); - var prefix = isGeneratorFunction(fn) ? '*' : ''; - - // Was this function defined with method notation? - if (fn.name === key && stringStartsWith(fnString, prefix + key + '(')) { - if (isValidVariableName(key)) { - // The function is already in valid method notation. - value = fnString; - } else { - // Reformat the opening of the function into valid method notation. - value = prefix + stringify(key) + fnString.substring(prefix.length + key.length); - } - - // Dedent the function, since it didn't come through regular stringification. - if (indent) { - value = dedentFunction(value); - } - - // Method notation includes the key, so there's no need to add it again below. - addKey = false; - } else { - // Not defined with method notation; delegate to regular stringification. - value = next(fn, key); - } + value = new FunctionParser().stringify(object[key], indent, key); + // The above function adds the key to the function string; this enables + // ES6 method notation to be used when appropriate. + addKey = false; } else { - // `object[key]` is not a function. value = next(object[key], key); + } - // Omit `undefined` object values. - if (value === undefined) { - return values; - } + // Omit `undefined` object values. + if (value === undefined) { + return values; } // String format the value data. @@ -226,7 +188,7 @@ if (addKey) { // String format the key data. - key = isValidVariableName(key) ? key : stringify(key); + key = stringifyKey(key); // Push the current object key and value into the values array. values.push(indent + key + ':' + (indent ? ' ' : '') + value); @@ -277,19 +239,278 @@ * @return {string} */ function stringifyFunction (fn, indent) { - var value = fn.toString(); + return new FunctionParser().stringify(fn, indent); + } + + function stringifyKey (key) { + return isValidVariableName(key) ? key : stringify(key); + } + + function FunctionParser () {} + + FunctionParser.prototype.stringify = function (fn, indent, key) { + this.fnString = Function.prototype.toString.call(fn); + this.fnType = fn.constructor.name; + this.fnName = fn.name; + this.key = key; + + var hasKey = key !== undefined; + this.keyPrefix = hasKey ? stringifyKey(key) + (indent ? ': ' : ':') : ''; + + // Methods with computed names will have empty function names in node 4, so + // empty named functions should still be candidates. + this.isMethodCandidate = hasKey && (this.fnName === '' || this.fnName === key); + + // These two properties are mutated while parsing the function. + this.pos = 0; + this.hadKeyword = false; + + var value = this.tryParse(); + if (!value) { + // If we can't stringify this function, return a void expression; for + // bonus help with debugging, include the function as a string literal. + return this.keyPrefix + 'void ' + stringify(this.fnString); + } if (indent) { value = dedentFunction(value); } - var prefix = isGeneratorFunction(fn) ? '*' : ''; - if (fn.name && stringStartsWith(value, prefix + fn.name + '(')) { - // Method notation was used to define this function, but it was transplanted from another object. - // Convert to regular function notation. - value = 'function' + prefix + ' ' + value.substring(prefix.length); - } return value; + }; + + FunctionParser.prototype.getPrefix = function () { + return this.isMethodCandidate && !this.hadKeyword ? + this.METHOD_PREFIXES[this.fnType] + stringifyKey(this.key) : + this.keyPrefix + this.FUNCTION_PREFIXES[this.fnType]; + }; + + FunctionParser.prototype.tryParse = function () { + var offset, result; + if (this.fnString[this.fnString.length - 1] !== '}') { + // Must be an arrow function + return this.keyPrefix + this.fnString; + } + + if (this.fnName && (result = this.tryStrippingName())) { + return result; + } + + if (this.tryParsePrefixTokens()) { + if (result = this.tryStrippingName()) { + return result; + } + offset = this.pos; + switch (this.consumeSyntax('WORDLIKE')) { + case 'WORDLIKE': + if (this.isMethodCandidate && !this.hadKeyword) { + offset = this.pos; + } + // fallthrough + case '()': + if (this.fnString.substring(this.pos, this.pos + 2) === '=>') { + return this.keyPrefix + this.fnString; + } + this.pos = offset; + // fallthrough + case '"': + case "'": + case '[]': + return this.getPrefix() + this.fnString.substring(this.pos); + } + } } + /** + * Attempt to parse the function from the current position by first stripping + * the function's name from the front. This is not a fool-proof method on all + * JavaScript engines, but yields good results on Node.js 4 (and slightly + * less good results on Node.js 6 and 8). + */ + FunctionParser.prototype.tryStrippingName = function () { + if (METHOD_NAMES_ARE_QUOTED) { + // ... then this approach is unnecessary (and potentially yields false positives). + return; + } + + var start = this.pos; + if (this.fnString.substring(this.pos, this.pos + this.fnName.length) === this.fnName) { + this.pos += this.fnName.length; + if (this.consumeSyntax() === '()' && this.consumeSyntax() === '{}' && this.pos === this.fnString.length) { + // Don't include the function's name if it will be included in the + // prefix, or if it's invalid as a name in a function expression. + if (this.isMethodCandidate || !isValidVariableName(this.fnName)) { + start += this.fnName.length; + } + return this.getPrefix() + this.fnString.substring(start); + } + } + this.pos = start; + } + + /** + * Attempt to advance the parser past the keywords expected to be at the + * start of this function's definition. This method sets `this.hadKeyword` + * based on whether or not a `function` keyword is consumed. + * + * @return {boolean} + */ + FunctionParser.prototype.tryParsePrefixTokens = function () { + var posPrev = this.pos, token; + this.hadKeyword = false; + switch (this.fnType) { + case 'AsyncFunction': + if (this.consumeSyntax() !== 'async') { + return false; + } + posPrev = this.pos; + // fallthrough + case 'Function': + if (this.consumeSyntax() === 'function') { + this.hadKeyword = true; + } else { + this.pos = posPrev; + } + return true; + case 'AsyncGeneratorFunction': + if (this.consumeSyntax() !== 'async') { + return false; + } + // fallthrough + case 'GeneratorFunction': + token = this.consumeSyntax(); + if (token === 'function') { + token = this.consumeSyntax(); + this.hadKeyword = true; + } + return token === '*'; + } + } + + /** + * Advance the parser past one element of JavaScript syntax. This could be a + * matched pair of delimiters, like braces or parentheses, or an atomic unit + * like a keyword, variable, or operator. Return a normalized string + * representation of the element parsed--for example, returns '{}' for a + * matched pair of braces. Comments and whitespace are skipped. + * + * (This isn't a full parser, so the token scanning logic used here is as + * simple as it can be. As a consequence, some things that are one token in + * JavaScript, like decimal number literals or most multicharacter operators + * like '&&', are split into more than one token here. However, awareness of + * some multicharacter sequences like '=>' is necessary, so we match the few + * of them that we care about.) + * + * @param {string} wordLikeToken Value to return in place of a word-like token, if one is detected. + * @return {string} + */ + FunctionParser.prototype.consumeSyntax = function (wordLikeToken) { + var match = this.consumeRegExp(/^(?:([A-Za-z_0-9$\xA0-\uFFFF]+)|=>|\+\+|\-\-|.)/); + if (!match) { + return; + } + this.consumeWhitespace(); + if (match[1]) { + return wordLikeToken || match[1]; + } + var token = match[0]; + switch (token) { + case '(': return this.consumeSyntaxUntil('(', ')'); + case '[': return this.consumeSyntaxUntil('[', ']'); + case '{': return this.consumeSyntaxUntil('{', '}'); + case '`': return this.consumeTemplate(); + case '"': return this.consumeRegExp(/^(?:[^\\"]|\\.)*"/, '"'); + case "'": return this.consumeRegExp(/^(?:[^\\']|\\.)*'/, "'"); + } + return token; + } + + FunctionParser.prototype.consumeSyntaxUntil = function (startToken, endToken) { + var isRegExpAllowed = true; + for (;;) { + var token = this.consumeSyntax(); + if (token === endToken) { + return startToken + endToken; + } + if (!token || token === ')' || token === ']' || token === '}') { + return; + } + if (token === '/' && isRegExpAllowed && this.consumeRegExp(/^(?:\\.|[^\\\/\n[]|\[(?:\\.|[^\]])*\])+\/[a-z]*/)) { + isRegExpAllowed = false; + this.consumeWhitespace(); + } else { + isRegExpAllowed = this.TOKENS_PRECEDING_REGEXPS.hasOwnProperty(token); + } + } + } + + /** + * Advance the parser past an arbitrary regular expression. Return `token`, + * or the match object of the regexp. + */ + FunctionParser.prototype.consumeRegExp = function (re, token) { + var match = re.exec(this.fnString.substring(this.pos)); + if (!match) { + return; + } + this.pos += match[0].length; + if (token) { + this.consumeWhitespace(); + } + return token || match; + } + + /** + * Advance the parser past a template string. + */ + FunctionParser.prototype.consumeTemplate = function () { + for (;;) { + var match = this.consumeRegExp(/^(?:[^`$\\]|\\.|\$(?!{))*/); + if (this.fnString[this.pos] === '`') { + this.pos++; + this.consumeWhitespace(); + return '`'; + } + if (this.fnString.substring(this.pos, this.pos + 2) === '${') { + this.pos += 2; + this.consumeWhitespace(); + if (this.consumeSyntaxUntil('{', '}')) { + continue; + } + } + return; + } + } + + /** + * Advance the parser past any whitespace or comments. + */ + FunctionParser.prototype.consumeWhitespace = function () { + this.consumeRegExp(/^(?:\s|\/\/.*|\/\*[^]*?\*\/)*/); + } + + FunctionParser.prototype.FUNCTION_PREFIXES = { + Function: 'function ', + GeneratorFunction: 'function* ', + AsyncFunction: 'async function ', + AsyncGeneratorFunction: 'async function* ', + }; + + FunctionParser.prototype.METHOD_PREFIXES = { + Function: '', + GeneratorFunction: '*', + AsyncFunction: 'async ', + AsyncGeneratorFunction: 'async *', + }; + + FunctionParser.prototype.TOKENS_PRECEDING_REGEXPS = {}; + + ( + 'case delete else in instanceof new return throw typeof void ' + + ', ; : + - ! ~ & | ^ * / % < > ? =' + ).split(' ').map(function (token) { + FunctionParser.prototype.TOKENS_PRECEDING_REGEXPS[token] = true; + }); + + /** * Convert JavaScript objects into strings. */ @@ -320,6 +541,8 @@ '[object RegExp]': String, '[object Function]': stringifyFunction, '[object GeneratorFunction]': stringifyFunction, + '[object AsyncFunction]': stringifyFunction, + '[object AsyncGeneratorFunction]': stringifyFunction, '[object global]': toGlobalVariable, '[object Window]': toGlobalVariable }; diff --git a/test.js b/test.js index 626e0e6..0cef69d 100644 --- a/test.js +++ b/test.js @@ -10,7 +10,44 @@ describe('javascript-stringify', function () { }; var testRoundTrip = function (insult, indent, options) { - return test(eval('(' + insult + ')'), insult, indent, options); + return function () { + return test(eval('(' + insult + ')'), insult, indent, options)(); + }; + }; + + var describeIfSupported = function (description, testExpr, body) { + try { + eval(testExpr); + } catch (e) { + return describe.skip(description, body); + } + return describe(description, body); + }; + + var cases = function (description, testCases) { + describe(description, function () { + for (var i = 0; i < testCases.length; i++) { + it('case ' + (i + 1), testCases[i]); + } + }); + }; + + var ifRunning = function (constraints, body) { + if (constraints.node && typeof process !== 'undefined') { + var nodeConstraints = constraints.node.split(','); + var nodeVersion = process.versions.node.split('.')[0]; + for (var i = 0; i < nodeConstraints.length; i++) { + var nodeConstraint = nodeConstraints[i]; + if (!isNaN(parseInt(nodeConstraint, 10))) { + nodeConstraint = '==' + nodeConstraint; + } + if (eval(nodeVersion + nodeConstraint)) { + return body; + } + } + return; + } + return body; }; describe('types', function () { @@ -134,6 +171,26 @@ describe('javascript-stringify', function () { 'should not need to reindent one-liners', testRoundTrip('{\n fn: function () { return; }\n}', 2) ); + + it( + 'should gracefully handle unexpected Function.toString formats', + function () { + var origToString = Function.prototype.toString; + Function.prototype.toString = function () { + return '{nope}'; + }; + try { + expect(stringify(function () {})).to.equal("void '{nope}'"); + } finally { + Function.prototype.toString = origToString; + } + } + ); + + cases('should not take the names of their keys', [ + testRoundTrip("{name:function () {}}"), + testRoundTrip("{'tricky name':function () {}}"), + ]); }); describe('native instances', function () { @@ -160,7 +217,7 @@ describe('javascript-stringify', function () { }); describe('Buffer', function () { - it('should stringify', test(new Buffer('test'), "new Buffer('test')")); + it('should stringify', typeof Buffer === 'function' && test(new Buffer('test'), "new Buffer('test')")); }); describe('Error', function () { @@ -168,7 +225,7 @@ describe('javascript-stringify', function () { }); describe('unknown native type', function () { - it('should be omitted', test({ k: process }, '{}')); + it('should be omitted', test({ k: typeof process === 'undefined' ? navigator : process }, '{}')); }); }); @@ -186,7 +243,13 @@ describe('javascript-stringify', function () { } describe('arrow functions', function () { - it('should stringify', testRoundTrip('(a, b) => a + b')); + cases('should stringify', [ + testRoundTrip('(a, b) => a + b'), + testRoundTrip('o => { return o.a + o.b; }'), + testRoundTrip('(a, b) => { if (a) { return b; } }'), + testRoundTrip('(a, b) => ({ [a]: b })'), + testRoundTrip('a => b => () => a + b'), + ]); it( 'should reindent function bodies', @@ -201,6 +264,14 @@ describe('javascript-stringify', function () { 2 ) ); + + describeIfSupported('arrows with patterns', '({x}) => x', function () { + cases('should stringify', [ + testRoundTrip("({ x, y }) => x + y"), + testRoundTrip("({ x, y }) => { if (x === '}') { return y; } }"), + testRoundTrip("({ x, y = /[/})]/.test(x) }) => { return y ? x : 0; }"), + ]); + }); }); describe('generators', function () { @@ -212,10 +283,13 @@ describe('javascript-stringify', function () { it('should stringify generator methods', testRoundTrip('{*a(b) { yield b; }}')); - it( - 'should not be fooled by tricky names', - testRoundTrip("{'function a'(b, c) { return b + c; }}") - ); + cases('should not be fooled by tricky names', [ + testRoundTrip("{'function a'(b, c) { return b + c; }}"), + testRoundTrip("{'a(a'(b, c) { return b + c; }}"), + testRoundTrip("{'() => function '() {}}"), + testRoundTrip("{'['() { return x[y]()\n { return true; }}}"), + testRoundTrip("{'() { return false;//'() { return true;\n}}"), + ]); it( 'should not be fooled by tricky generator names', @@ -227,14 +301,97 @@ describe('javascript-stringify', function () { testRoundTrip("{''(b, c) { return b + c; }}") ); - it( - 'should not be fooled by arrow functions', - testRoundTrip("{a:(b, c) => b + c}") - ); + cases('should not be fooled by arrow functions', [ + testRoundTrip("{a:(b, c) => b + c}"), + testRoundTrip("{a:a => a + 1}"), + function () { + var fn = eval('({ "() => ": () => () => 42 })')['() => ']; + expect(stringify(fn)).to.equal('() => () => 42'); + }, + testRoundTrip("{'() => ':() => () => 42}"), + testRoundTrip('{\'() => "\':() => "() {//"}'), + testRoundTrip('{\'() => "\':() => "() {`//"}'), + testRoundTrip('{\'() => "\':() => "() {`${//"}'), + testRoundTrip('{\'() => "\':() => "() {/*//"}'), + + ifRunning({ node: '<=4,>=10' }, testRoundTrip("{'a => function ':a => function () { return a + 1; }}")), + ]); + + cases('should not be fooled by regexp literals', [ + testRoundTrip("{' '(s) { return /}/.test(s); }}"), + testRoundTrip("{' '(s) { return /abc/ .test(s); }}"), + testRoundTrip("{' '() { return x / y; // /}\n }}"), + testRoundTrip("{' '() { return / y; }//* } */}}"), + + testRoundTrip("{' '() { return delete / y; }/.x}}"), + testRoundTrip("{' '() { switch (x) { case / y; }}/: }}}"), + testRoundTrip("{' '() { if (x) return; else / y;}/; }}"), + testRoundTrip("{' '() { return x in / y;}/; }}"), + testRoundTrip("{' '() { return x instanceof / y;}/; }}"), + testRoundTrip("{' '() { return new / y;}/.x; }}"), + testRoundTrip("{' '() { throw / y;}/.x; }}"), + testRoundTrip("{' '() { return typeof / y;}/; }}"), + testRoundTrip("{' '() { void / y;}/; }}"), + testRoundTrip("{' '() { return x, / y;}/; }}"), + testRoundTrip("{' '() { return x; / y;}/; }}"), + testRoundTrip("{' '() { return { x: / y;}/ }; }}"), + testRoundTrip("{' '() { return x + / y;}/.x; }}"), + testRoundTrip("{' '() { return x - / y;}/.x; }}"), + testRoundTrip("{' '() { return !/ y;}/; }}"), + testRoundTrip("{' '() { return ~/ y;}/.x; }}"), + testRoundTrip("{' '() { return x && / y;}/; }}"), + testRoundTrip("{' '() { return x || / y;}/; }}"), + testRoundTrip("{' '() { return x ^ / y;}/.x; }}"), + testRoundTrip("{' '() { return x * / y;}/.x; }}"), + testRoundTrip("{' '() { return x / / y;}/.x; }}"), + testRoundTrip("{' '() { return x % / y;}/.x; }}"), + testRoundTrip("{' '() { return x < / y;}/.x; }}"), + testRoundTrip("{' '() { return x > / y;}/.x; }}"), + testRoundTrip("{' '() { return x <= / y;}/.x; }}"), + testRoundTrip("{' '() { return x /= / y;}/.x; }}"), + testRoundTrip("{' '() { return x ? / y;}/ : false; }}"), + ]); + + cases('should not be fooled by computed names', [ + test(eval('({ ["foobar".slice(3)](x) { return x + 1; } })'), '{bar(x) { return x + 1; }}'), + test( + eval('({[((s,a,b)=>a+s(a)+","+s(b)+b)(JSON.stringify,"[((s,a,b)=>a+s(a)+\\",\\"+s(b)+b)(JSON.stringify,",")]() {}")]() {}})'), + "{'[((s,a,b)=>a+s(a)+\",\"+s(b)+b)(JSON.stringify,\"[((s,a,b)=>a+s(a)+\\\\\",\\\\\"+s(b)+b)(JSON.stringify,\",\")]() {}\")]() {}'() {}}" + ), + test( + eval('({[`over${`6${"0".repeat(3)}`.replace("6", "9")}`]() { this.activateHair(); }})'), + '{over9000() { this.activateHair(); }}' + ), + test(eval('({["() {\'"]() {\'\'}})'), "{'() {\\\''() {\'\'}}"), + test(eval('({["() {`"]() {``}})'), "{'() {`'() {``}}"), + test(eval('({["() {/*"]() {/*`${()=>{/*}*/}})'), "{'() {/*'() {/*`${()=>{/*}*/}}"), + ]); + + // These two cases demonstrate that branching on + // METHOD_NAMES_ARE_QUOTED is unavoidable--you can't write code + // without it that will pass both of these cases on both node.js 4 + // and node.js 10. (If you think you can, consider that the name and + // toString of the first case when executed on node.js 10 are + // identical to the name and toString of the second case when + // executed on node.js 4, so good luck telling them apart without + // knowing which node you're on.) + cases('should handle different versions of node correctly', [ + test( + eval('({[((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;}})'), + '{\'[((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;}}' + ), + test( + eval('({\'[((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;}})'), + '{\'[((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;}}' + ), + ]); it( - 'should not be fooled by no-parentheses arrow functions', - testRoundTrip("{a:a => a + 1}") + 'should not be fooled by comments', + test( + eval("({'method' /* a comment! */ () /* another comment! */ {}})"), + "{method() /* another comment! */ {}}" + ) ); it('should stringify extracted methods', function () { @@ -247,10 +404,17 @@ describe('javascript-stringify', function () { expect(stringify(fn)).to.equal('function* foo(x) { yield x; }'); }); - // It's difficult to disambiguate between this and the arrow function case. Since the latter is probably - // much more common than this pattern (who creates empty-named methods ever?), we don't even try. But this - // test is here as documentation of a known limitation of this feature. - it.skip('should stringify extracted methods with empty names', function () { + it('should stringify extracted methods with tricky names', function () { + var fn = eval('({ "a(a"(x) { return x + 1; } })')['a(a']; + expect(stringify(fn)).to.equal('function (x) { return x + 1; }'); + }); + + it('should stringify extracted methods with arrow-like tricky names', function () { + var fn = eval('({ "() => function "(x) { return x + 1; } })')['() => function ']; + expect(stringify(fn)).to.equal('function (x) { return x + 1; }'); + }); + + it('should stringify extracted methods with empty names', function () { var fn = eval('({ ""(x) { return x + 1; } })')['']; expect(stringify(fn)).to.equal('function (x) { return x + 1; }'); }); @@ -284,8 +448,64 @@ describe('javascript-stringify', function () { } }); + describe('ES2017', function () { + describeIfSupported('async functions', '(async function () {})', function () { + it('should stringify', testRoundTrip('async function (x) { await x; }')); + + it( + 'should gracefully handle unexpected Function.toString formats', + function () { + var origToString = Function.prototype.toString; + Function.prototype.toString = function () { + return '{nope}'; + }; + try { + expect(stringify(eval('(async function () {})'))).to.equal("void '{nope}'"); + } finally { + Function.prototype.toString = origToString; + } + } + ); + }); + + describeIfSupported('async arrows', 'async () => {}', function () { + cases('should stringify', [ + testRoundTrip('async (x) => x + 1'), + testRoundTrip('async x => x + 1'), + testRoundTrip('async x => { await x.then(y => y + 1); }'), + ]); + + cases('should stringify as object properties', [ + testRoundTrip("{f:async a => a + 1}"), + ifRunning({ node: '<=4,>=10' }, testRoundTrip("{'async a => function ':async a => function () { return a + 1; }}")), + ]); + }); + }); + + describe('ES2018', function () { + describeIfSupported('async generators', '(async function* () {})', function () { + it('should stringify', testRoundTrip('async function* (x) { yield x; }')); + + it( + 'should gracefully handle unexpected Function.toString formats', + function () { + var origToString = Function.prototype.toString; + Function.prototype.toString = function () { + return '{nope}'; + }; + try { + expect(stringify(eval('(async function* () {})'))).to.equal("void '{nope}'"); + } finally { + Function.prototype.toString = origToString; + } + } + ); + }); + }); + describe('global', function () { it('should access the global in the current environment', function () { + var global = new Function('return this')(); expect(eval(stringify(global))).to.equal(global); }); });