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/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" } } } 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); }); });