From c6acf33b6c712f471e84b893c960a3c66dc5960a Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sat, 7 Apr 2018 11:26:03 -0400 Subject: [PATCH 1/2] Add support for ES6 function notations Ensure that function values produced with the following ES6 notations are stringified into valid code: * Arrow functions `(a, b) => a + b` * Generators `function* (x) { yield x; }` * Method notation `{ add(a, b) { return a + b; } }` --- javascript-stringify.js | 92 +++++++++++++++++++++++++++++++++++++---- test.js | 71 +++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/javascript-stringify.js b/javascript-stringify.js index 89c06ae..682c368 100644 --- a/javascript-stringify.js +++ b/javascript-stringify.js @@ -81,6 +81,27 @@ 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; + } + /** * Return the global variable name. * @@ -149,19 +170,54 @@ function stringifyObject (object, indent, next) { // Iterate over object keys and concat string together. var values = Object.keys(object).reduce(function (values, key) { - var value = next(object[key], key); + 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); + } - // Omit `undefined` object values. - if (value === undefined) { - return values; + // 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); + } + } else { + // `object[key]` is not a function. + value = next(object[key], key); + + // Omit `undefined` object values. + if (value === undefined) { + return values; + } } - // String format the key and value data. - key = isValidVariableName(key) ? key : stringify(key); + // String format the value data. value = String(value).split('\n').join('\n' + indent); - // Push the current object key and value into the values array. - values.push(indent + key + ':' + (indent ? ' ' : '') + value); + if (addKey) { + // String format the key data. + key = isValidVariableName(key) ? key : stringify(key); + + // Push the current object key and value into the values array. + values.push(indent + key + ':' + (indent ? ' ' : '') + value); + } else { + // Push just the value; this is a method and no key is needed. + values.push(indent + value); + } return values; }, []).join(indent ? ',\n' : ','); @@ -174,6 +230,23 @@ return '{' + values + '}'; } + /** + * Stringify a function. + * + * @param {Function} fn + * @return {string} + */ + function stringifyFunction (fn, indent) { + var value = fn.toString(); + 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; + } + /** * Convert JavaScript objects into strings. */ @@ -202,7 +275,8 @@ return 'new Map(' + stringify(Array.from(array), indent, next) + ')'; }, '[object RegExp]': String, - '[object Function]': String, + '[object Function]': stringifyFunction, + '[object GeneratorFunction]': stringifyFunction, '[object global]': toGlobalVariable, '[object Window]': toGlobalVariable }; diff --git a/test.js b/test.js index c6527a0..9a48922 100644 --- a/test.js +++ b/test.js @@ -8,6 +8,10 @@ describe('javascript-stringify', function () { }; }; + var testRoundTrip = function (insult, indent, options) { + return test(eval('(' + insult + ')'), insult, indent, options); + }; + describe('types', function () { describe('booleans', function () { it('should be stringified', test(true, 'true')); @@ -127,6 +131,73 @@ describe('javascript-stringify', function () { it('should stringify', test(new Set(['key', 'value']), "new Set(['key','value'])")); }); } + + describe('arrow functions', function () { + it('should stringify', testRoundTrip('(a, b) => a + b')); + }); + + describe('generators', function () { + it('should stringify', testRoundTrip('function* (x) { yield x; }')); + }); + + describe('method notation', function () { + it('should stringify', testRoundTrip('{a(b, c) { return b + c; }}')); + + 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; }}") + ); + + it( + 'should not be fooled by tricky generator names', + testRoundTrip("{*'function a'(b, c) { return b + c; }}") + ); + + it( + 'should not be fooled by empty names', + testRoundTrip("{''(b, c) { return b + c; }}") + ); + + it( + 'should not be fooled by arrow functions', + testRoundTrip("{a:(b, c) => b + c}") + ); + + it( + 'should not be fooled by no-parentheses arrow functions', + testRoundTrip("{a:a => a + 1}") + ); + + it('should stringify extracted methods', function () { + var fn = eval('({ foo(x) { return x + 1; } })').foo; + expect(stringify(fn)).to.equal('function foo(x) { return x + 1; }'); + }); + + it('should stringify extracted generators', function () { + var fn = eval('({ *foo(x) { yield x; } })').foo; + 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 () { + var fn = eval('({ ""(x) { return x + 1; } })')['']; + expect(stringify(fn)).to.equal('function (x) { return x + 1; }'); + }); + + it('should handle transplanted names', function () { + var fn = eval('({ foo(x) { return x + 1; } })').foo; + expect(stringify({ bar: fn })).to.equal('{bar:function foo(x) { return x + 1; }}'); + }); + + it('should handle transplanted names with generators', function () { + var fn = eval('({ *foo(x) { yield x; } })').foo; + expect(stringify({ bar: fn })).to.equal('{bar:function* foo(x) { yield x; }}'); + }); + }); } }); From 6be4cd4097a4b705ae0208d2927979b4098046f3 Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Sat, 7 Apr 2018 11:26:08 -0400 Subject: [PATCH 2/2] Improve function body indentation Remove extra indentation from function bodies before stringifying them, where extra indentation is determined by finding the line in the function body (after the first line) with the fewest initial space characters. --- javascript-stringify.js | 45 ++++++++++++++++++++++- package.json | 2 +- test.js | 80 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/javascript-stringify.js b/javascript-stringify.js index 682c368..0643af3 100644 --- a/javascript-stringify.js +++ b/javascript-stringify.js @@ -102,6 +102,17 @@ return str.substring(0, prefix.length) === prefix; } + /** + * Can be replaced with `str.repeat(count)` if code is updated to ES6. + * + * @param {string} str + * @param {number} count + * @return {string} + */ + function stringRepeat (str, count) { + return new Array(Math.max(0, count|0) + 1).join(str); + } + /** * Return the global variable name. * @@ -189,6 +200,11 @@ 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 { @@ -230,6 +246,30 @@ return '{' + values + '}'; } + /** + * Rewrite a stringified function to remove initial indentation. + * + * @param {string} fnString + * @return {string} + */ + function dedentFunction (fnString) { + var indentationRegExp = /\n */g; + var match; + + // Find the minimum amount of indentation used in the function body. + var dedent = Infinity; + while (match = indentationRegExp.exec(fnString)) { + dedent = Math.min(dedent, match[0].length - 1); + } + + if (isFinite(dedent)) { + return fnString.split('\n' + stringRepeat(' ', dedent)).join('\n'); + } else { + // Function is a one-liner and needs no adjustment. + return fnString; + } + } + /** * Stringify a function. * @@ -238,6 +278,9 @@ */ function stringifyFunction (fn, indent) { var value = fn.toString(); + 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. @@ -335,7 +378,7 @@ // Convert the spaces into a string. if (typeof space !== 'string') { - space = new Array(Math.max(0, space|0) + 1).join(' '); + space = stringRepeat(' ', space); } var maxDepth = Number(options.maxDepth) || 100; diff --git a/package.json b/package.json index 5d71d45..17d08d9 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "javascript-stringify.d.ts" ], "scripts": { - "test": "istanbul cover node_modules/mocha/bin/_mocha -- -R spec" + "test": "istanbul cover node_modules/mocha/bin/_mocha -x test.js -- -R spec" }, "repository": "https://github.com/blakeembrey/javascript-stringify.git", "keywords": [ diff --git a/test.js b/test.js index 9a48922..dd6db42 100644 --- a/test.js +++ b/test.js @@ -83,6 +83,56 @@ describe('javascript-stringify', function () { ); }); + describe('functions', function () { + it( + 'should reindent function bodies', + test( + function () { + if (true) { + return "hello"; + } + }, + 'function () {\n if (true) {\n return "hello";\n }\n}', + 2 + ) + ); + + it( + 'should reindent function bodies in objects', + test( + { + fn: function () { + if (true) { + return "hello"; + } + } + }, + '{\n fn: function () {\n if (true) {\n return "hello";\n }\n }\n}', + 2 + ) + ); + + it( + 'should reindent function bodies in arrays', + test( + [ + function () { + if (true) { + return "hello"; + } + } + ], + '[\n function () {\n if (true) {\n return "hello";\n }\n }\n]', + 2 + ) + ); + + it( + 'should not need to reindent one-liners', + testRoundTrip('{\n fn: function () { return; }\n}', 2) + ); + }); + describe('native instances', function () { describe('Date', function () { var date = new Date(); @@ -134,6 +184,20 @@ describe('javascript-stringify', function () { describe('arrow functions', function () { it('should stringify', testRoundTrip('(a, b) => a + b')); + + it( + 'should reindent function bodies', + test( + eval( +' (() => {\n' + +' if (true) {\n' + +' return "hello";\n' + +' }\n' + +' })'), + '() => {\n if (true) {\n return "hello";\n }\n}', + 2 + ) + ); }); describe('generators', function () { @@ -197,6 +261,22 @@ describe('javascript-stringify', function () { var fn = eval('({ *foo(x) { yield x; } })').foo; expect(stringify({ bar: fn })).to.equal('{bar:function* foo(x) { yield x; }}'); }); + + it( + 'should reindent methods', + test( + eval( +' ({\n' + +' fn() {\n' + +' if (true) {\n' + +' return "hello";\n' + +' }\n' + +' }\n' + +' })'), + '{\n fn() {\n if (true) {\n return "hello";\n }\n }\n}', + 2 + ) + ); }); } });