Skip to content

Commit d67b657

Browse files
rhendricblakeembrey
authored andcommitted
Add support for ES6 function notations (#16)
1 parent 3c2fd3b commit d67b657

3 files changed

Lines changed: 279 additions & 11 deletions

File tree

javascript-stringify.js

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,38 @@
8181
return !RESERVED_WORDS.hasOwnProperty(name) && IS_VALID_IDENTIFIER.test(name);
8282
}
8383

84+
/**
85+
* Check if a function is an ES6 generator function
86+
*
87+
* @param {Function} fn
88+
* @return {boolean}
89+
*/
90+
function isGeneratorFunction (fn) {
91+
return fn.constructor.name === 'GeneratorFunction';
92+
}
93+
94+
/**
95+
* Can be replaced with `str.startsWith(prefix)` if code is updated to ES6.
96+
*
97+
* @param {string} str
98+
* @param {string} prefix
99+
* @return {boolean}
100+
*/
101+
function stringStartsWith (str, prefix) {
102+
return str.substring(0, prefix.length) === prefix;
103+
}
104+
105+
/**
106+
* Can be replaced with `str.repeat(count)` if code is updated to ES6.
107+
*
108+
* @param {string} str
109+
* @param {number} count
110+
* @return {string}
111+
*/
112+
function stringRepeat (str, count) {
113+
return new Array(Math.max(0, count|0) + 1).join(str);
114+
}
115+
84116
/**
85117
* Return the global variable name.
86118
*
@@ -149,19 +181,59 @@
149181
function stringifyObject (object, indent, next) {
150182
// Iterate over object keys and concat string together.
151183
var values = Object.keys(object).reduce(function (values, key) {
152-
var value = next(object[key], key);
184+
var value;
185+
var addKey = true;
186+
187+
// Handle functions specially to detect method notation.
188+
if (typeof object[key] === 'function') {
189+
var fn = object[key];
190+
var fnString = fn.toString();
191+
var prefix = isGeneratorFunction(fn) ? '*' : '';
192+
193+
// Was this function defined with method notation?
194+
if (fn.name === key && stringStartsWith(fnString, prefix + key + '(')) {
195+
if (isValidVariableName(key)) {
196+
// The function is already in valid method notation.
197+
value = fnString;
198+
} else {
199+
// Reformat the opening of the function into valid method notation.
200+
value = prefix + stringify(key) + fnString.substring(prefix.length + key.length);
201+
}
202+
203+
// Dedent the function, since it didn't come through regular stringification.
204+
if (indent) {
205+
value = dedentFunction(value);
206+
}
153207

154-
// Omit `undefined` object values.
155-
if (value === undefined) {
156-
return values;
208+
// Method notation includes the key, so there's no need to add it again below.
209+
addKey = false;
210+
} else {
211+
// Not defined with method notation; delegate to regular stringification.
212+
value = next(fn, key);
213+
}
214+
} else {
215+
// `object[key]` is not a function.
216+
value = next(object[key], key);
217+
218+
// Omit `undefined` object values.
219+
if (value === undefined) {
220+
return values;
221+
}
157222
}
158223

159-
// String format the key and value data.
160-
key = isValidVariableName(key) ? key : stringify(key);
224+
// String format the value data.
161225
value = String(value).split('\n').join('\n' + indent);
162226

163-
// Push the current object key and value into the values array.
164-
values.push(indent + key + ':' + (indent ? ' ' : '') + value);
227+
if (addKey) {
228+
// String format the key data.
229+
key = isValidVariableName(key) ? key : stringify(key);
230+
231+
// Push the current object key and value into the values array.
232+
values.push(indent + key + ':' + (indent ? ' ' : '') + value);
233+
} else {
234+
// Push just the value; this is a method and no key is needed.
235+
values.push(indent + value);
236+
}
165237

166238
return values;
167239
}, []).join(indent ? ',\n' : ',');
@@ -174,6 +246,50 @@
174246
return '{' + values + '}';
175247
}
176248

249+
/**
250+
* Rewrite a stringified function to remove initial indentation.
251+
*
252+
* @param {string} fnString
253+
* @return {string}
254+
*/
255+
function dedentFunction (fnString) {
256+
var indentationRegExp = /\n */g;
257+
var match;
258+
259+
// Find the minimum amount of indentation used in the function body.
260+
var dedent = Infinity;
261+
while (match = indentationRegExp.exec(fnString)) {
262+
dedent = Math.min(dedent, match[0].length - 1);
263+
}
264+
265+
if (isFinite(dedent)) {
266+
return fnString.split('\n' + stringRepeat(' ', dedent)).join('\n');
267+
} else {
268+
// Function is a one-liner and needs no adjustment.
269+
return fnString;
270+
}
271+
}
272+
273+
/**
274+
* Stringify a function.
275+
*
276+
* @param {Function} fn
277+
* @return {string}
278+
*/
279+
function stringifyFunction (fn, indent) {
280+
var value = fn.toString();
281+
if (indent) {
282+
value = dedentFunction(value);
283+
}
284+
var prefix = isGeneratorFunction(fn) ? '*' : '';
285+
if (fn.name && stringStartsWith(value, prefix + fn.name + '(')) {
286+
// Method notation was used to define this function, but it was transplanted from another object.
287+
// Convert to regular function notation.
288+
value = 'function' + prefix + ' ' + value.substring(prefix.length);
289+
}
290+
return value;
291+
}
292+
177293
/**
178294
* Convert JavaScript objects into strings.
179295
*/
@@ -202,7 +318,8 @@
202318
return 'new Map(' + stringify(Array.from(array), indent, next) + ')';
203319
},
204320
'[object RegExp]': String,
205-
'[object Function]': String,
321+
'[object Function]': stringifyFunction,
322+
'[object GeneratorFunction]': stringifyFunction,
206323
'[object global]': toGlobalVariable,
207324
'[object Window]': toGlobalVariable
208325
};
@@ -263,7 +380,7 @@
263380

264381
// Convert the spaces into a string.
265382
if (typeof space !== 'string') {
266-
space = new Array(Math.max(0, space|0) + 1).join(' ');
383+
space = stringRepeat(' ', space);
267384
}
268385

269386
var maxDepth = Number(options.maxDepth) || 100;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"javascript-stringify.d.ts"
1010
],
1111
"scripts": {
12-
"test": "istanbul cover node_modules/mocha/bin/_mocha -- -R spec"
12+
"test": "istanbul cover node_modules/mocha/bin/_mocha -x test.js -- -R spec"
1313
},
1414
"repository": "https://github.com/blakeembrey/javascript-stringify.git",
1515
"keywords": [

test.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ describe('javascript-stringify', function () {
99
};
1010
};
1111

12+
var testRoundTrip = function (insult, indent, options) {
13+
return test(eval('(' + insult + ')'), insult, indent, options);
14+
};
15+
1216
describe('types', function () {
1317
describe('booleans', function () {
1418
it('should be stringified', test(true, 'true'));
@@ -82,6 +86,56 @@ describe('javascript-stringify', function () {
8286
);
8387
});
8488

89+
describe('functions', function () {
90+
it(
91+
'should reindent function bodies',
92+
test(
93+
function () {
94+
if (true) {
95+
return "hello";
96+
}
97+
},
98+
'function () {\n if (true) {\n return "hello";\n }\n}',
99+
2
100+
)
101+
);
102+
103+
it(
104+
'should reindent function bodies in objects',
105+
test(
106+
{
107+
fn: function () {
108+
if (true) {
109+
return "hello";
110+
}
111+
}
112+
},
113+
'{\n fn: function () {\n if (true) {\n return "hello";\n }\n }\n}',
114+
2
115+
)
116+
);
117+
118+
it(
119+
'should reindent function bodies in arrays',
120+
test(
121+
[
122+
function () {
123+
if (true) {
124+
return "hello";
125+
}
126+
}
127+
],
128+
'[\n function () {\n if (true) {\n return "hello";\n }\n }\n]',
129+
2
130+
)
131+
);
132+
133+
it(
134+
'should not need to reindent one-liners',
135+
testRoundTrip('{\n fn: function () { return; }\n}', 2)
136+
);
137+
});
138+
85139
describe('native instances', function () {
86140
describe('Date', function () {
87141
var date = new Date();
@@ -130,6 +184,103 @@ describe('javascript-stringify', function () {
130184
it('should stringify', test(new Set(['key', 'value']), "new Set(['key','value'])"));
131185
});
132186
}
187+
188+
describe('arrow functions', function () {
189+
it('should stringify', testRoundTrip('(a, b) => a + b'));
190+
191+
it(
192+
'should reindent function bodies',
193+
test(
194+
eval(
195+
' (() => {\n' +
196+
' if (true) {\n' +
197+
' return "hello";\n' +
198+
' }\n' +
199+
' })'),
200+
'() => {\n if (true) {\n return "hello";\n }\n}',
201+
2
202+
)
203+
);
204+
});
205+
206+
describe('generators', function () {
207+
it('should stringify', testRoundTrip('function* (x) { yield x; }'));
208+
});
209+
210+
describe('method notation', function () {
211+
it('should stringify', testRoundTrip('{a(b, c) { return b + c; }}'));
212+
213+
it('should stringify generator methods', testRoundTrip('{*a(b) { yield b; }}'));
214+
215+
it(
216+
'should not be fooled by tricky names',
217+
testRoundTrip("{'function a'(b, c) { return b + c; }}")
218+
);
219+
220+
it(
221+
'should not be fooled by tricky generator names',
222+
testRoundTrip("{*'function a'(b, c) { return b + c; }}")
223+
);
224+
225+
it(
226+
'should not be fooled by empty names',
227+
testRoundTrip("{''(b, c) { return b + c; }}")
228+
);
229+
230+
it(
231+
'should not be fooled by arrow functions',
232+
testRoundTrip("{a:(b, c) => b + c}")
233+
);
234+
235+
it(
236+
'should not be fooled by no-parentheses arrow functions',
237+
testRoundTrip("{a:a => a + 1}")
238+
);
239+
240+
it('should stringify extracted methods', function () {
241+
var fn = eval('({ foo(x) { return x + 1; } })').foo;
242+
expect(stringify(fn)).to.equal('function foo(x) { return x + 1; }');
243+
});
244+
245+
it('should stringify extracted generators', function () {
246+
var fn = eval('({ *foo(x) { yield x; } })').foo;
247+
expect(stringify(fn)).to.equal('function* foo(x) { yield x; }');
248+
});
249+
250+
// It's difficult to disambiguate between this and the arrow function case. Since the latter is probably
251+
// much more common than this pattern (who creates empty-named methods ever?), we don't even try. But this
252+
// test is here as documentation of a known limitation of this feature.
253+
it.skip('should stringify extracted methods with empty names', function () {
254+
var fn = eval('({ ""(x) { return x + 1; } })')[''];
255+
expect(stringify(fn)).to.equal('function (x) { return x + 1; }');
256+
});
257+
258+
it('should handle transplanted names', function () {
259+
var fn = eval('({ foo(x) { return x + 1; } })').foo;
260+
expect(stringify({ bar: fn })).to.equal('{bar:function foo(x) { return x + 1; }}');
261+
});
262+
263+
it('should handle transplanted names with generators', function () {
264+
var fn = eval('({ *foo(x) { yield x; } })').foo;
265+
expect(stringify({ bar: fn })).to.equal('{bar:function* foo(x) { yield x; }}');
266+
});
267+
268+
it(
269+
'should reindent methods',
270+
test(
271+
eval(
272+
' ({\n' +
273+
' fn() {\n' +
274+
' if (true) {\n' +
275+
' return "hello";\n' +
276+
' }\n' +
277+
' }\n' +
278+
' })'),
279+
'{\n fn() {\n if (true) {\n return "hello";\n }\n }\n}',
280+
2
281+
)
282+
);
283+
});
133284
}
134285
});
135286

0 commit comments

Comments
 (0)