diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/index.js b/index.js index ef30c9b..a6f40c7 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,11 @@ -var json = typeof JSON !== 'undefined' ? JSON : require('jsonify'); - module.exports = function (obj, opts) { if (!opts) opts = {}; if (typeof opts === 'function') opts = { cmp: opts }; + var space = opts.space || ''; + if (typeof space === 'number') space = Array(space+1).join(' '); + var cycles = (typeof opts.cycles === 'boolean') ? opts.cycles : false; + var replacer = opts.replacer || function(key, value) { return value; }; + var cmp = opts.cmp && (function (f) { return function (node) { return function (a, b) { @@ -12,28 +15,57 @@ module.exports = function (obj, opts) { }; }; })(opts.cmp); - - return (function stringify (node) { - if (typeof node !== 'object') { - return json.stringify(node); + + var seen = []; + return (function stringify (parent, key, node, level) { + var indent = space ? ('\n' + new Array(level + 1).join(space)) : ''; + var colonSeparator = space ? ': ' : ':'; + + if (node && node.toJSON && typeof node.toJSON === 'function') { + node = node.toJSON(); + } + + node = replacer.call(parent, key, node); + + if (node === undefined) { + return; + } + if (typeof node !== 'object' || node === null) { + return JSON.stringify(node); } if (isArray(node)) { var out = []; for (var i = 0; i < node.length; i++) { - out.push(stringify(node[i])); + var item = stringify(node, i, node[i], level+1) || JSON.stringify(null); + out.push(indent + space + item); } - return '[' + out.join(',') + ']'; + return '[' + out.join(',') + indent + ']'; } else { + if (seen.indexOf(node) !== -1) { + if (cycles) return JSON.stringify('__cycle__'); + throw new TypeError('Converting circular structure to JSON'); + } + else seen.push(node); + var keys = objectKeys(node).sort(cmp && cmp(node)); var out = []; for (var i = 0; i < keys.length; i++) { var key = keys[i]; - out.push(stringify(key) + ':' + stringify(node[key])); + var value = stringify(node, key, node[key], level+1); + + if(!value) continue; + + var keyValue = JSON.stringify(key) + + colonSeparator + + value; + ; + out.push(indent + space + keyValue); } - return '{' + out.join(',') + '}'; + seen.splice(seen.indexOf(node), 1); + return '{' + out.join(',') + indent + '}'; } - })(obj); + })({ '': obj }, '', obj, 0); }; var isArray = Array.isArray || function (x) { diff --git a/package.json b/package.json index 3693d85..2184fea 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,9 @@ { - "name": "json-stable-stringify", - "version": "0.0.0", - "description": "deterministic JSON.stringify() with custom sorting to get deterministic hashes from stringified results", + "name": "json-stable-stringify-without-jsonify", + "version": "1.0.1", + "description": "deterministic JSON.stringify() with custom sorting to get deterministic hashes from stringified results, with no public domain dependencies", "main": "index.js", "dependencies": { - "jsonify": "~0.0.0" }, "devDependencies": { "tape": "~1.0.4" @@ -24,9 +23,9 @@ }, "repository": { "type": "git", - "url": "git://github.com/substack/json-stable-stringify.git" + "url": "git://github.com/samn/json-stable-stringify.git" }, - "homepage": "https://github.com/substack/json-stable-stringify", + "homepage": "https://github.com/samn/json-stable-stringify", "keywords": [ "json", "stringify", @@ -40,5 +39,8 @@ "email": "mail@substack.net", "url": "http://substack.net" }, - "license": "MIT" + "license": "MIT", + "files": [ + "index.js" + ] } diff --git a/readme.markdown b/readme.markdown index 3a3d00c..e95b468 100644 --- a/readme.markdown +++ b/readme.markdown @@ -1,5 +1,7 @@ # json-stable-stringify +This is the same as https://github.com/substack/json-stable-stringify but it doesn't depend on libraries without licenses (jsonify). + deterministic version of `JSON.stringify()` so you can get a consistent hash from stringified results @@ -33,6 +35,10 @@ var stringify = require('json-stable-stringify') Return a deterministic stringified string `str` from the object `obj`. +## options + +### cmp + If `opts` is given, you can supply an `opts.cmp` to have a custom comparison function for object keys. Your function `opts.cmp` is called with these parameters: @@ -77,6 +83,42 @@ which outputs: {"d":6,"c":5,"b":[{"z":3,"y":2,"x":1},9],"a":10} ``` +### space + +If you specify `opts.space`, it will indent the output for pretty-printing. +Valid values are strings (e.g. `{space: \t}`) or a number of spaces +(`{space: 3}`). + +For example: + +```js +var obj = { b: 1, a: { foo: 'bar', and: [1, 2, 3] } }; +var s = stringify(obj, { space: ' ' }); +console.log(s); +``` + +which outputs: + +``` +{ + "a": { + "and": [ + 1, + 2, + 3 + ], + "foo": "bar" + }, + "b": 1 +} +``` + +### replacer + +The replacer parameter is a function `opts.replacer(key, value)` that behaves +the same as the replacer +[from the core JSON object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_native_JSON#The_replacer_parameter). + # install With [npm](https://npmjs.org) do: diff --git a/test/nested.js b/test/nested.js index 43459f2..052c7d6 100644 --- a/test/nested.js +++ b/test/nested.js @@ -6,3 +6,37 @@ test('nested', function (t) { var obj = { c: 8, b: [{z:6,y:5,x:4},7], a: 3 }; t.equal(stringify(obj), '{"a":3,"b":[{"x":4,"y":5,"z":6},7],"c":8}'); }); + +test('cyclic (default)', function (t) { + t.plan(1); + var one = { a: 1 }; + var two = { a: 2, one: one }; + one.two = two; + try { + stringify(one); + } catch (ex) { + t.equal(ex.toString(), 'TypeError: Converting circular structure to JSON'); + } +}); + +test('cyclic (specifically allowed)', function (t) { + t.plan(1); + var one = { a: 1 }; + var two = { a: 2, one: one }; + one.two = two; + t.equal(stringify(one, {cycles:true}), '{"a":1,"two":{"a":2,"one":"__cycle__"}}'); +}); + +test('repeated non-cyclic value', function(t) { + t.plan(1); + var one = { x: 1 }; + var two = { a: one, b: one }; + t.equal(stringify(two), '{"a":{"x":1},"b":{"x":1}}'); +}); + +test('acyclic but with reused obj-property pointers', function (t) { + t.plan(1); + var x = { a: 1 } + var y = { b: x, c: x } + t.equal(stringify(y), '{"b":{"a":1},"c":{"a":1}}'); +}); diff --git a/test/replacer.js b/test/replacer.js new file mode 100644 index 0000000..98802a7 --- /dev/null +++ b/test/replacer.js @@ -0,0 +1,74 @@ +var test = require('tape'); +var stringify = require('../'); + +test('replace root', function (t) { + t.plan(1); + + var obj = { a: 1, b: 2, c: false }; + var replacer = function(key, value) { return 'one'; }; + + t.equal(stringify(obj, { replacer: replacer }), '"one"'); +}); + +test('replace numbers', function (t) { + t.plan(1); + + var obj = { a: 1, b: 2, c: false }; + var replacer = function(key, value) { + if(value === 1) return 'one'; + if(value === 2) return 'two'; + return value; + }; + + t.equal(stringify(obj, { replacer: replacer }), '{"a":"one","b":"two","c":false}'); +}); + +test('replace with object', function (t) { + t.plan(1); + + var obj = { a: 1, b: 2, c: false }; + var replacer = function(key, value) { + if(key === 'b') return { d: 1 }; + if(value === 1) return 'one'; + return value; + }; + + t.equal(stringify(obj, { replacer: replacer }), '{"a":"one","b":{"d":"one"},"c":false}'); +}); + +test('replace with undefined', function (t) { + t.plan(1); + + var obj = { a: 1, b: 2, c: false }; + var replacer = function(key, value) { + if(value === false) return; + return value; + }; + + t.equal(stringify(obj, { replacer: replacer }), '{"a":1,"b":2}'); +}); + +test('replace with array', function (t) { + t.plan(1); + + var obj = { a: 1, b: 2, c: false }; + var replacer = function(key, value) { + if(key === 'b') return ['one', 'two']; + return value; + }; + + t.equal(stringify(obj, { replacer: replacer }), '{"a":1,"b":["one","two"],"c":false}'); +}); + +test('replace array item', function (t) { + t.plan(1); + + var obj = { a: 1, b: 2, c: [1,2] }; + var replacer = function(key, value) { + if(value === 1) return 'one'; + if(value === 2) return 'two'; + return value; + }; + + t.equal(stringify(obj, { replacer: replacer }), '{"a":"one","b":"two","c":["one","two"]}'); +}); diff --git a/test/space.js b/test/space.js new file mode 100644 index 0000000..2621122 --- /dev/null +++ b/test/space.js @@ -0,0 +1,59 @@ +var test = require('tape'); +var stringify = require('../'); + +test('space parameter', function (t) { + t.plan(1); + var obj = { one: 1, two: 2 }; + t.equal(stringify(obj, {space: ' '}), '' + + '{\n' + + ' "one": 1,\n' + + ' "two": 2\n' + + '}' + ); +}); + +test('space parameter (with tabs)', function (t) { + t.plan(1); + var obj = { one: 1, two: 2 }; + t.equal(stringify(obj, {space: '\t'}), '' + + '{\n' + + '\t"one": 1,\n' + + '\t"two": 2\n' + + '}' + ); +}); + +test('space parameter (with a number)', function (t) { + t.plan(1); + var obj = { one: 1, two: 2 }; + t.equal(stringify(obj, {space: 3}), '' + + '{\n' + + ' "one": 1,\n' + + ' "two": 2\n' + + '}' + ); +}); + +test('space parameter (nested objects)', function (t) { + t.plan(1); + var obj = { one: 1, two: { b: 4, a: [2,3] } }; + t.equal(stringify(obj, {space: ' '}), '' + + '{\n' + + ' "one": 1,\n' + + ' "two": {\n' + + ' "a": [\n' + + ' 2,\n' + + ' 3\n' + + ' ],\n' + + ' "b": 4\n' + + ' }\n' + + '}' + ); +}); + +test('space parameter (same as native)', function (t) { + t.plan(1); + // for this test, properties need to be in alphabetical order + var obj = { one: 1, two: { a: [2,3], b: 4 } }; + t.equal(stringify(obj, {space: ' '}), JSON.stringify(obj, null, ' ')); +}); diff --git a/test/str.js b/test/str.js index 02c97fb..67426b9 100644 --- a/test/str.js +++ b/test/str.js @@ -3,6 +3,30 @@ var stringify = require('../'); test('simple object', function (t) { t.plan(1); - var obj = { c: 6, b: [4,5], a: 3 }; - t.equal(stringify(obj), '{"a":3,"b":[4,5],"c":6}'); + var obj = { c: 6, b: [4,5], a: 3, z: null }; + t.equal(stringify(obj), '{"a":3,"b":[4,5],"c":6,"z":null}'); +}); + +test('object with undefined', function (t) { + t.plan(1); + var obj = { a: 3, z: undefined }; + t.equal(stringify(obj), '{"a":3}'); +}); + +test('array with undefined', function (t) { + t.plan(1); + var obj = [4, undefined, 6]; + t.equal(stringify(obj), '[4,null,6]'); +}); + +test('object with empty string', function (t) { + t.plan(1); + var obj = { a: 3, z: '' }; + t.equal(stringify(obj), '{"a":3,"z":""}'); +}); + +test('array with empty string', function (t) { + t.plan(1); + var obj = [4, '', 6]; + t.equal(stringify(obj), '[4,"",6]'); }); diff --git a/test/to-json.js b/test/to-json.js new file mode 100644 index 0000000..ef9a980 --- /dev/null +++ b/test/to-json.js @@ -0,0 +1,20 @@ +var test = require('tape'); +var stringify = require('../'); + +test('toJSON function', function (t) { + t.plan(1); + var obj = { one: 1, two: 2, toJSON: function() { return { one: 1 }; } }; + t.equal(stringify(obj), '{"one":1}' ); +}); + +test('toJSON returns string', function (t) { + t.plan(1); + var obj = { one: 1, two: 2, toJSON: function() { return 'one'; } }; + t.equal(stringify(obj), '"one"'); +}); + +test('toJSON returns array', function (t) { + t.plan(1); + var obj = { one: 1, two: 2, toJSON: function() { return ['one']; } }; + t.equal(stringify(obj), '["one"]'); +});