Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Tests
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm i

- name: Test
run: npm run test
16 changes: 12 additions & 4 deletions benchmark.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { faker } = require('@faker-js/faker');
const chalk = require('chalk');
const fastJsonFormat = require('./src/index.js');
const JSONbig = require('json-bigint');
const LosslessJSON = require('lossless-json');

/**
* Generates a nested JSON string of approximately the target size
Expand Down Expand Up @@ -75,7 +76,7 @@ const testSizes = [
];

console.log('\n' + chalk.bold.cyan('🚀 Fast JSON Format Benchmark') + '\n');
console.log(chalk.gray('⚡ Comparing ') + chalk.yellow('fast-json-format') + chalk.gray(' vs ') + chalk.yellow('JSON.stringify(JSON.parse())') + chalk.gray(' vs ') + chalk.yellow('json-bigint') + '\n');
console.log(chalk.gray('⚡ Comparing ') + chalk.yellow('fast-json-format') + chalk.gray(' vs ') + chalk.yellow('JSON.stringify(JSON.parse())') + chalk.gray(' vs ') + chalk.yellow('json-bigint') + chalk.gray(' vs ') + chalk.yellow('lossless-json') + '\n');
console.log(chalk.bold.blue('📊 Generating test data...') + '\n');

const testCases = testSizes.map(size => {
Expand Down Expand Up @@ -110,6 +111,9 @@ testCases.forEach(testCase => {
.add('json-bigint', function() {
JSONbig.stringify(JSONbig.parse(testCase.data), null, 2);
})
.add('lossless-json', function() {
LosslessJSON.stringify(LosslessJSON.parse(testCase.data), null, 2);
})
.add('JSON.stringify', function() {
JSON.stringify(JSON.parse(testCase.data), null, 2);
})
Expand All @@ -121,7 +125,7 @@ testCases.forEach(testCase => {
results.push({ name, hz: event.target.hz });

const symbol = results.length === 1 ? '├─' : results.length === 2 ? '├─' : '└─';
const color = name === 'fast-json-format' ? chalk.green : name === 'JSON.stringify' ? chalk.blue : chalk.magenta;
const color = name === 'fast-json-format' ? chalk.green : name === 'JSON.stringify' ? chalk.blue : name === 'json-bigint' ? chalk.magenta : chalk.yellow;

console.log(chalk.gray(` ${symbol} `) + color(name) + chalk.gray(': ') + chalk.bold.white(ops) + chalk.gray(' ops/sec ±' + margin + '%'));
})
Expand All @@ -143,32 +147,36 @@ testCases.forEach(testCase => {
console.log('\n\n' + chalk.bold.cyan('📊 Summary Table') + '\n');

// Build table header
const libs = ['fast-json-format', 'json-bigint', 'JSON.stringify'];
const libs = ['fast-json-format', 'json-bigint', 'lossless-json', 'JSON.stringify'];
const colWidths = { size: 12, lib: 20 };

// Header
console.log(
chalk.bold.white('Size'.padEnd(colWidths.size)) + ' │ ' +
chalk.bold.green('fast-json-format'.padEnd(colWidths.lib)) + ' │ ' +
chalk.bold.magenta('json-bigint'.padEnd(colWidths.lib)) + ' │ ' +
chalk.bold.yellow('lossless-json'.padEnd(colWidths.lib)) + ' │ ' +
chalk.bold.blue('JSON.stringify'.padEnd(colWidths.lib))
);
console.log('─'.repeat(colWidths.size) + '─┼─' + '─'.repeat(colWidths.lib) + '─┼─' + '─'.repeat(colWidths.lib) + '─┼─' + '─'.repeat(colWidths.lib));
console.log('─'.repeat(colWidths.size) + '─┼─' + '─'.repeat(colWidths.lib) + '─┼─' + '─'.repeat(colWidths.lib) + '─┼─' + '─'.repeat(colWidths.lib) + '─┼─' + '─'.repeat(colWidths.lib));

// Rows
allResults.forEach(result => {
const fastJson = result.results.find(r => r.name === 'fast-json-format');
const jsonBigint = result.results.find(r => r.name === 'json-bigint');
const losslessJson = result.results.find(r => r.name === 'lossless-json');
const jsonStringify = result.results.find(r => r.name === 'JSON.stringify');

const fastJsonOps = fastJson ? fastJson.hz.toFixed(0) : 'N/A';
const jsonBigintOps = jsonBigint ? jsonBigint.hz.toFixed(0) : 'N/A';
const losslessJsonOps = losslessJson ? losslessJson.hz.toFixed(0) : 'N/A';
const jsonStringifyOps = jsonStringify ? jsonStringify.hz.toFixed(0) : 'N/A';

console.log(
chalk.cyan(result.size.padEnd(colWidths.size)) + ' │ ' +
chalk.white((fastJsonOps + ' ops/sec').padEnd(colWidths.lib)) + ' │ ' +
chalk.white((jsonBigintOps + ' ops/sec').padEnd(colWidths.lib)) + ' │ ' +
chalk.white((losslessJsonOps + ' ops/sec').padEnd(colWidths.lib)) + ' │ ' +
chalk.white((jsonStringifyOps + ' ops/sec').padEnd(colWidths.lib))
);
});
Expand Down
14 changes: 11 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fast-json-format",
"version": "0.1.0",
"version": "0.4.0",
"description": "Fast JSON formatting library",
"main": "src/index.js",
"keywords": [
Expand All @@ -9,8 +9,14 @@
"fast",
"json-format"
],
"files": [
"src",
"package.json",
"readme.md"
],
"author": "Bruno Software Inc.",
"scripts": {
"prepack": "npm run test",
"benchmark": "node benchmark.js",
"test": "jest tests/*.spec.js"
},
Expand All @@ -19,7 +25,8 @@
"benchmark": "^2.1.4",
"chalk": "^4.1.2",
"jest": "^30.2.0",
"json-bigint": "^1.0.0"
"json-bigint": "^1.0.0",
"lossless-json": "^4.3.0"
},
"license": "MIT"
}
12 changes: 6 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ JSON.stringify is inherently faster (as it’s native and C++-optimized)
Performance improvements are welcome :)

```text
Size │ fast-json-format │ json-bigint │ JSON.stringify
─────────────┼──────────────────────┼──────────────────────┼─────────────────────
100 KB │ 1060 ops/sec │ 679 ops/sec │ 2394 ops/sec
1 MB │ 90 ops/sec │ 68 ops/sec │ 223 ops/sec
5 MB │ 15 ops/sec │ 13 ops/sec │ 48 ops/sec
10 MB │ 7 ops/sec │ 6 ops/sec │ 23 ops/sec
Size │ fast-json-format │ json-bigint │ lossless-json │ JSON.stringify
─────────────┼──────────────────────┼──────────────────────┼──────────────────────┼─────────────────────
100 KB │ 1064 ops/sec │ 712 ops/sec │ 609 ops/sec │ 2432 ops/sec
1 MB │ 91 ops/sec │ 65 ops/sec │ 43 ops/sec │ 238 ops/sec
5 MB │ 15 ops/sec │ 13 ops/sec │ 6 ops/sec │ 47 ops/sec
10 MB │ 7 ops/sec │ 7 ops/sec │ 3 ops/sec │ 23 ops/sec
```

## Testing
Expand Down
89 changes: 77 additions & 12 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Pretty-prints a JSON-like string without parsing.
* Fast path: chunked copying, fast string scan, lookahead for empty {} / [].
* Decodes \uXXXX unicode sequences and \/ forward slash escapes for readability.
*
* @param {string} input
* @param {string} indent
Expand Down Expand Up @@ -45,6 +46,7 @@ function fastJsonFormat(input, indent = ' ') {
// Character codes
const QUOTE = 34; // "
const BACKSLASH = 92; // \
const FORWARD_SLASH = 47;// /
const OPEN_BRACE = 123; // {
const CLOSE_BRACE = 125; // }
const OPEN_BRACKET = 91; // [
Expand All @@ -65,33 +67,96 @@ function fastJsonFormat(input, indent = ' ') {
return idx;
};

// Helper: check if character code is a valid hex digit (0-9, A-F, a-f)
const isHexDigit = (code) => {
return (code >= 48 && code <= 57) || // 0-9
(code >= 65 && code <= 70) || // A-F
(code >= 97 && code <= 102); // a-f
};

// Helper: parse 4 hex digits starting at position j
// Returns -1 if invalid, otherwise the code point
const parseHex4 = (j) => {
if (j + 4 > n) return -1;
const c1 = s.charCodeAt(j);
const c2 = s.charCodeAt(j + 1);
const c3 = s.charCodeAt(j + 2);
const c4 = s.charCodeAt(j + 3);
if (!isHexDigit(c1) || !isHexDigit(c2) || !isHexDigit(c3) || !isHexDigit(c4)) {
return -1;
}
// Fast hex parsing without parseInt
let val = 0;
// First digit
val = c1 <= 57 ? c1 - 48 : (c1 <= 70 ? c1 - 55 : c1 - 87);
// Second digit
val = (val << 4) | (c2 <= 57 ? c2 - 48 : (c2 <= 70 ? c2 - 55 : c2 - 87));
// Third digit
val = (val << 4) | (c3 <= 57 ? c3 - 48 : (c3 <= 70 ? c3 - 55 : c3 - 87));
// Fourth digit
val = (val << 4) | (c4 <= 57 ? c4 - 48 : (c4 <= 70 ? c4 - 55 : c4 - 87));
return val;
};

// Scan a JSON string starting at index of opening quote `i` (s[i] === '"').
// Returns index just after the closing quote and pushes the entire slice.
// Returns index just after the closing quote and decodes \uXXXX and \/ sequences.
const scanString = (i) => {
out.push('"'); // opening quote
let j = i + 1;
let lastCopy = j; // track where we last copied from

while (j < n) {
const c = s.charCodeAt(j);
if (c === QUOTE) { // end of string
j++;
out.push(s.slice(i, j));
return j;
// Copy any remaining content before the closing quote
if (j > lastCopy) {
out.push(s.slice(lastCopy, j));
}
out.push('"'); // closing quote
return j + 1;
}
if (c === BACKSLASH) {
// Handle escape: \" \\ \/ \b \f \n \r \t or \uXXXX
const backslashPos = j;
j++;
if (j < n && s.charCodeAt(j) === 117 /* 'u' */) {
// Skip 'u' + 4 hex digits if present
// (Keep it forgiving; don't validate hex strictly)
j += 5; // 'u' + 4 chars
} else {
j++; // skip the escaped char
// Found \uXXXX - try to decode it to actual unicode character
const codePoint = parseHex4(j + 1);

if (codePoint >= 0) {
// Valid hex sequence - decode it
// Copy everything up to the backslash
if (backslashPos > lastCopy) {
out.push(s.slice(lastCopy, backslashPos));
}
// Convert to actual unicode character
out.push(String.fromCharCode(codePoint));
j += 5; // skip 'u' + 4 hex digits
lastCopy = j;
continue;
}
// If parsing failed, reset and let it be copied as-is
j = backslashPos + 1;
} else if (j < n && s.charCodeAt(j) === FORWARD_SLASH) {
// Found \/ - decode to / for readability
// Copy everything up to the backslash
if (backslashPos > lastCopy) {
out.push(s.slice(lastCopy, backslashPos));
}
out.push('/');
j++; // skip the forward slash
lastCopy = j;
continue;
}
// For other escapes (or invalid \u), just skip the escaped char
if (j < n) j++;
continue;
}
j++;
}
// Unterminated: copy to end (forgiving)
out.push(s.slice(i, n));
// Unterminated: copy remaining content (forgiving)
if (n > lastCopy) {
out.push(s.slice(lastCopy, n));
}
return n;
};

Expand Down
51 changes: 51 additions & 0 deletions tests/escaped.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,55 @@ describe('escaped characters', () => {
}`;
assertEqual(input, expected);
});
});

describe('forward slash escape sequences', () => {
it('should decode \\/ escape sequences to forward slashes', () => {
const input = '{"url":"https:\\/\\/example.com\\/api\\/v1"}';
const expected = `{
"url": "https://example.com/api/v1"
}`;
assertEqual(input, expected);
});

it('should handle unescaped forward slashes correctly', () => {
const input = '{"url":"https://example.com/api/v1"}';
const expected = `{
"url": "https://example.com/api/v1"
}`;
assertEqual(input, expected);
});

it('should handle forward slashes mixed with other escape sequences', () => {
const input = '{"text":"line1\\npath\\/to\\/file\\ttab","unicode":"\\u4e16\\u754c\\/path"}';
const expected = `{
"text": "line1\\npath/to/file\\ttab",
"unicode": "世界/path"
}`;
assertEqual(input, expected);
});

it('should handle a single escaped forward slash', () => {
const input = '{"slash":"\\/"}';
const expected = `{
"slash": "/"
}`;
assertEqual(input, expected);
});

it('should handle multiple consecutive escaped forward slashes', () => {
const input = '{"path":"\\/\\/network\\/share"}';
const expected = `{
"path": "//network/share"
}`;
assertEqual(input, expected);
});

it('should handle escaped forward slash at end of string', () => {
const input = '{"url":"https://example.com\\/"}';
const expected = `{
"url": "https://example.com/"
}`;
assertEqual(input, expected);
});
});
Loading