Skip to content

Commit 5fa0977

Browse files
authored
lang-json: improve tests, add FastJSON grammar (#579)
1 parent 5d42247 commit 5fa0977

6 files changed

Lines changed: 209 additions & 30 deletions

File tree

packages/lang-json/bench.ts

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,37 @@ import {readFileSync} from 'node:fs';
22
import path from 'node:path';
33
import process from 'node:process';
44
import {fileURLToPath} from 'node:url';
5+
import {parseArgs} from 'node:util';
56

67
import {Bench} from 'tinybench';
78
import {grammar} from '@ohm-js/compiler/compat';
89

910
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1011

11-
const ohmSource = readFileSync(path.join(__dirname, 'json.ohm'), 'utf-8');
12-
const json = grammar(ohmSource);
12+
const json = grammar(readFileSync(path.join(__dirname, 'json.ohm'), 'utf-8'));
13+
const fastjson = grammar(readFileSync(path.join(__dirname, 'fastjson.ohm'), 'utf-8'));
1314

1415
// The benchmark file assigns a JSON string to `self.sample`.
1516
// Evaluate it to extract the actual JSON.
1617
const jsSource = readFileSync(path.join(__dirname, 'test/data/1K_json.js'), 'utf-8');
1718
const self: Record<string, string> = {};
1819
new Function('self', jsSource)(self);
19-
const input = self.sample;
20+
let input = self.sample;
2021

21-
// Sanity check: verify JSON.parse and our grammar both accept it.
22-
JSON.parse(input);
23-
json.match(input).use(r => {
24-
if (!r.succeeded()) throw new Error('Match failed');
22+
const {values} = parseArgs({
23+
options: {'small-size': {type: 'boolean', default: false}},
2524
});
26-
console.error(`Input: 1K_json.js (${(input.length / 1024).toFixed(0)}KB)`);
25+
const smallSize = values['small-size'];
2726

28-
const smallSize = process.argv.includes('--small-size');
29-
const iterations = smallSize ? 1 : 10;
27+
// For 'small-size' just test some random JSON.
28+
if (smallSize) {
29+
input = '{ "extends": "../tsconfig.base.json", "include": ["*.ts", "test/**/*.ts"] }';
30+
}
3031

3132
const bench = new Bench({
32-
iterations,
33+
iterations: smallSize ? 1 : 10,
3334
time: 0,
34-
warmup: true,
35+
warmup: !smallSize,
3536
});
3637

3738
const opts = {
@@ -40,21 +41,9 @@ const opts = {
4041
},
4142
};
4243

43-
bench.add(
44-
'ohm (wasm)',
45-
() => {
46-
json.match(input).use(r => r.succeeded());
47-
},
48-
opts
49-
);
50-
51-
bench.add(
52-
'JSON.parse',
53-
() => {
54-
JSON.parse(input);
55-
},
56-
opts
57-
);
44+
bench.add('JSON', () => json.match(input).use(r => r.succeeded()), opts);
45+
bench.add('FastJSON', () => fastjson.match(input).use(r => r.succeeded()), opts);
46+
bench.add('JSON.parse', () => JSON.parse(input), opts);
5847

5948
(async () => {
6049
await bench.run();

packages/lang-json/fastjson.ohm

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
FastJSON {
2+
Value =
3+
stringLiteral
4+
| Object
5+
| Array
6+
| number
7+
| "true"
8+
| "false"
9+
| "null"
10+
11+
Object =
12+
"{" (stringLiteral ":" Value ("," stringLiteral ":" Value)*)? "}"
13+
14+
Array =
15+
"[" (Value ("," Value)*)? "]"
16+
17+
stringLiteral =
18+
"\"" ("#".."[" | "]".."\uffff" | " ".."!" | "\\\"" | "\\\\" | "\\/" | "\\b" | "\\f" | "\\n" | "\\r" | "\\t" | unicodeEscape | "\u0000".."\u001f")* "\""
19+
20+
unicodeEscape = "\\u" hexDigit hexDigit hexDigit hexDigit
21+
22+
number =
23+
"-"? ("0" | nat) ("." digit+)? (("e" | "E") ("+" | "-")? digit+)?
24+
25+
nat = "1".."9" digit*
26+
}

packages/lang-json/json.ohm

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
2222
*/
2323
JSON {
24-
Start = Value
25-
2624
Value =
2725
Object
2826
| Array

packages/lang-json/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
"description": "A JSON grammar for Ohm",
66
"type": "module",
77
"scripts": {
8-
"test": "node --test test/test-json.ts && node bench.ts --small-size"
8+
"test": "node --test 'test/*.ts' && node bench.ts --small-size"
99
},
1010
"dependencies": {
1111
"@ohm-js/compiler": "workspace:*",
1212
"ohm-js": "workspace:*"
1313
},
1414
"devDependencies": {
15+
"fast-check": "^4.2.0",
1516
"tinybench": "^6.0.0"
1617
},
1718
"license": "MIT"
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import {fileURLToPath} from 'node:url';
6+
7+
import fc from 'fast-check';
8+
import {grammar} from '@ohm-js/compiler/compat';
9+
10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+
const jsonG = grammar(fs.readFileSync(path.join(__dirname, '../json.ohm'), 'utf-8'));
12+
const fastG = grammar(fs.readFileSync(path.join(__dirname, '../fastjson.ohm'), 'utf-8'));
13+
14+
function accepts(g: any, input: string): boolean {
15+
let result = false;
16+
g.match(input).use((r: any) => {
17+
result = r.succeeded();
18+
});
19+
return result;
20+
}
21+
22+
function assertAgree(input: string) {
23+
const j = accepts(jsonG, input);
24+
const f = accepts(fastG, input);
25+
assert.strictEqual(
26+
f,
27+
j,
28+
`Grammars disagree on ${JSON.stringify(input)}: json=${j}, fastjson=${f}`
29+
);
30+
}
31+
32+
/** Produce mutations of a valid JSON string to explore boundary cases. */
33+
function mutate(s: string): string[] {
34+
return [
35+
s.replace(/([:,\[]\s*)([1-9]\d*)/g, '$10$2'), // leading zeros
36+
s.replace(/./, c => c + c), // duplicate first char
37+
s.replace(/([\]\}])/, ',$1'), // trailing comma
38+
s.replace(/\d/, ''), // remove digit
39+
s.replace(/([:,\[]\s*)(\d)/, '$1+$2'), // leading +
40+
];
41+
}
42+
43+
test('targeted number forms', () => {
44+
for (const c of [
45+
'0',
46+
'-0',
47+
'1',
48+
'-1',
49+
'10',
50+
'100',
51+
'0.1',
52+
'-0.1',
53+
'1.0',
54+
'1.23',
55+
'1e2',
56+
'1E2',
57+
'1e+2',
58+
'1e-2',
59+
'1E+2',
60+
'1E-2',
61+
'0e1',
62+
'0E1',
63+
'-0e1',
64+
'1.5e10',
65+
'-1.5E-10',
66+
// Invalid forms
67+
'01',
68+
'00',
69+
'-01',
70+
'-00',
71+
'00.5',
72+
'012',
73+
'+1',
74+
'+0',
75+
'.1',
76+
'1.',
77+
'1.e2',
78+
'1e',
79+
'1e+',
80+
'1e-',
81+
'1E',
82+
'--1',
83+
'++1',
84+
'',
85+
'-',
86+
'+',
87+
'0x1',
88+
'0b1',
89+
'0o1',
90+
'NaN',
91+
'Infinity',
92+
'-Infinity',
93+
]) {
94+
assertAgree(c);
95+
}
96+
});
97+
98+
test('targeted string forms', () => {
99+
for (const c of [
100+
'""',
101+
'"hello"',
102+
'"\\n"',
103+
'"\\t"',
104+
'"\\r"',
105+
'"\\\\"',
106+
'"\\/"',
107+
'"\\""',
108+
'"\\b"',
109+
'"\\f"',
110+
'"\\u0041"',
111+
'"\\u00e9"',
112+
'"\\uFFFF"',
113+
// Invalid
114+
'"\\x41"',
115+
'"\\v"',
116+
"'hello'",
117+
]) {
118+
assertAgree(c);
119+
}
120+
});
121+
122+
test('structural edge cases', () => {
123+
for (const c of [
124+
'{}',
125+
'[]',
126+
'{"a": 1}',
127+
'{"a": 1, "b": 2}',
128+
'[1]',
129+
'[1, 2, 3]',
130+
'{"a": {"b": {"c": 1}}}',
131+
'[[[]]]',
132+
// Invalid
133+
'{,}',
134+
'[,]',
135+
'{"a": 1,}',
136+
'[1,]',
137+
'{1: "a"}',
138+
'',
139+
]) {
140+
assertAgree(c);
141+
}
142+
});
143+
144+
test('fast-check: grammars agree on valid JSON', () => {
145+
fc.assert(
146+
fc.property(fc.json(), json => {
147+
assertAgree(json);
148+
}),
149+
{numRuns: 200}
150+
);
151+
});
152+
153+
test('fast-check: grammars agree on mutations of valid JSON', () => {
154+
fc.assert(
155+
fc.property(fc.json(), json => {
156+
for (const m of mutate(json)) {
157+
assertAgree(m);
158+
}
159+
}),
160+
{numRuns: 200}
161+
);
162+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)