feat: add native JSON5 parser (Bun.JSON5) (#26439)

## Summary

- Adds `Bun.JSON5.parse()` and `Bun.JSON5.stringify()` as built-in APIs
- Adds `.json5` file support in the module resolver and bundler
- Parser uses a scanner/parser split architecture with a labeled switch
pattern (like the YAML parser) — the scanner produces typed tokens, the
parser never touches source bytes directly
- 430+ tests covering the official JSON5 test suite, escape sequences,
numbers, comments, whitespace (including all Unicode whitespace types),
unquoted/reserved-word keys, unicode identifiers, deeply nested
structures, garbage input, error messages, and stringify behavior

<img width="659" height="610" alt="Screenshot 2026-01-25 at 12 19 57 AM"
src="https://github.com/user-attachments/assets/e300125a-f197-4cad-90ed-e867b6232a01"
/>

## Test plan

- [x] `bun bd test test/js/bun/json5/json5.test.ts` — 317 tests
- [x] `bun bd test test/js/bun/json5/json5-test-suite.test.ts` — 113
tests from the official JSON5 test suite
- [x] `bun bd test test/js/bun/resolve/json5/json5.test.js` — .json5
module resolution

closes #3175

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Dylan Conway
2026-01-26 10:52:35 -08:00
committed by GitHub
parent 6130aa8168
commit b59c77a6e7
36 changed files with 4932 additions and 92 deletions

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env bun
/**
* Generates json5-test-suite.test.ts from the official json5/json5-tests repository.
*
* Usage:
* bun run test/js/bun/json5/generate_json5_test_suite.ts [path-to-json5-tests]
*
* If no path is given, clones json5/json5-tests into a temp directory.
* Requires the `json5` npm package (installed in bench/json5/).
*/
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
// ---------------------------------------------------------------------------
// 1. Locate json5-tests
// ---------------------------------------------------------------------------
let testsDir = process.argv[2];
if (!testsDir) {
const tmp = mkdtempSync(join(tmpdir(), "json5-tests-"));
console.log(`Cloning json5/json5-tests into ${tmp} ...`);
execSync(`git clone --depth 1 https://github.com/json5/json5-tests.git ${tmp}`, { stdio: "inherit" });
testsDir = tmp;
}
// ---------------------------------------------------------------------------
// 2. Discover test files grouped by category
// ---------------------------------------------------------------------------
interface TestCase {
name: string; // human-readable name derived from filename
input: string; // raw file contents
isError: boolean; // .txt / .js files are error cases
errorMessage?: string; // our parser's error message for this input
expected?: unknown; // parsed value for valid inputs
isNaN?: boolean; // special handling for NaN
}
interface Category {
name: string;
tests: TestCase[];
}
const CATEGORIES = ["arrays", "comments", "misc", "new-lines", "numbers", "objects", "strings", "todo"];
function nameFromFile(filename: string): string {
// strip extension, convert hyphens to spaces
return basename(filename)
.replace(/\.[^.]+$/, "")
.replace(/-/g, " ");
}
// The json5 npm package resolve from bench/json5 where it's installed
const json5PkgPath = join(import.meta.dir, "../../../../bench/json5/node_modules/json5");
const JSON5Ref = require(json5PkgPath) as { parse: (s: string) => unknown };
function getExpected(input: string): { value: unknown; isNaN: boolean } {
const value = JSON5Ref.parse(input);
if (typeof value === "number" && Number.isNaN(value)) {
return { value, isNaN: true };
}
return { value, isNaN: false };
}
function getErrorMessage(input: string): string {
try {
(Bun as any).JSON5.parse(input);
throw new Error("Expected parse to fail but it succeeded");
} catch (e: any) {
// Format: "JSON5 Parse error: <message>"
const msg: string = e.message;
const prefix = "JSON5 Parse error: ";
if (msg.startsWith(prefix)) {
return msg.slice(prefix.length);
}
return msg;
}
}
const categories: Category[] = [];
for (const cat of CATEGORIES) {
const catDir = join(testsDir, cat);
if (!existsSync(catDir)) continue;
const files = readdirSync(catDir)
.filter(f => /\.(json5?|txt|js)$/.test(f))
.sort();
const tests: TestCase[] = [];
for (const file of files) {
const name = nameFromFile(file);
const filepath = join(catDir, file);
const input = readFileSync(filepath, "utf8");
const isError = /\.(txt|js)$/.test(file);
if (isError) {
const errorMessage = getErrorMessage(input);
tests.push({ name, input, isError, errorMessage });
} else {
const { value, isNaN: isNaNValue } = getExpected(input);
tests.push({ name, input, isError, expected: value, isNaN: isNaNValue });
}
}
categories.push({ name: cat, tests });
}
// ---------------------------------------------------------------------------
// 3. Code generation helpers
// ---------------------------------------------------------------------------
function escapeJSString(s: string): string {
let result = "";
for (const ch of s) {
switch (ch) {
case "\\":
result += "\\\\";
break;
case '"':
result += '\\"';
break;
case "\n":
result += "\\n";
break;
case "\r":
result += "\\r";
break;
case "\t":
result += "\\t";
break;
case "\b":
result += "\\b";
break;
case "\f":
result += "\\f";
break;
default:
if (ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f) {
result += `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
} else {
result += ch;
}
break;
}
}
return `"${result}"`;
}
function valueToJS(val: unknown, indent: number = 0): string {
if (val === null) return "null";
if (val === undefined) return "undefined";
if (typeof val === "boolean") return String(val);
if (typeof val === "number") {
if (val === Infinity) return "Infinity";
if (val === -Infinity) return "-Infinity";
if (Object.is(val, -0)) return "-0";
// Strip "+" from exponent: 2e+23 -> 2e23
return String(val).replace("e+", "e");
}
if (typeof val === "string") {
return JSON.stringify(val);
}
if (Array.isArray(val)) {
if (val.length === 0) return "[]";
const items = val.map(v => valueToJS(v, indent + 1));
// Simple arrays on one line if short
const oneLine = `[${items.join(", ")}]`;
if (oneLine.length < 80 && !oneLine.includes("\n")) return oneLine;
const pad = " ".repeat(indent + 1);
const endPad = " ".repeat(indent);
return `[\n${items.map(i => `${pad}${i},`).join("\n")}\n${endPad}]`;
}
if (typeof val === "object") {
const entries = Object.entries(val as Record<string, unknown>);
if (entries.length === 0) return "{}";
const parts = entries.map(([k, v]) => {
const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k);
return `${key}: ${valueToJS(v, indent + 1)}`;
});
// Simple objects on one line if short
const oneLine = `{ ${parts.join(", ")} }`;
if (oneLine.length < 80 && !oneLine.includes("\n")) return oneLine;
const pad = " ".repeat(indent + 1);
const endPad = " ".repeat(indent);
return `{\n${parts.map(p => `${pad}${p},`).join("\n")}\n${endPad}}`;
}
return String(val);
}
// ---------------------------------------------------------------------------
// 4. Generate the test file
// ---------------------------------------------------------------------------
let output = `// Tests generated from json5/json5-tests official test suite
// Expected values verified against json5@2.2.3 reference implementation
import { JSON5 } from "bun";
import { describe, expect, test } from "bun:test";
`;
for (const cat of categories) {
output += `\ndescribe("${cat.name}", () => {\n`;
for (let i = 0; i < cat.tests.length; i++) {
const tc = cat.tests[i];
const inputStr = escapeJSString(tc.input);
const testName = tc.isError ? `${tc.name} (throws)` : tc.name;
const separator = i < cat.tests.length - 1 ? "\n" : "";
if (tc.isError) {
output += ` test(${JSON.stringify(testName)}, () => {\n`;
output += ` const input: string = ${inputStr};\n`;
output += ` expect(() => JSON5.parse(input)).toThrow(${JSON.stringify(tc.errorMessage)});\n`;
output += ` });\n${separator}`;
} else if (tc.isNaN) {
output += ` test(${JSON.stringify(testName)}, () => {\n`;
output += ` const input: string = ${inputStr};\n`;
output += ` const parsed = JSON5.parse(input);\n`;
output += ` expect(Number.isNaN(parsed)).toBe(true);\n`;
output += ` });\n${separator}`;
} else {
const expectedStr = valueToJS(tc.expected!, 2);
output += ` test(${JSON.stringify(testName)}, () => {\n`;
output += ` const input: string = ${inputStr};\n`;
output += ` const parsed = JSON5.parse(input);\n`;
output += ` const expected: any = ${expectedStr};\n`;
output += ` expect(parsed).toEqual(expected);\n`;
output += ` });\n${separator}`;
}
}
output += `});\n`;
}
const suffix = process.argv.includes("--check") ? "2" : "";
const outPath = join(import.meta.dir, `json5-test-suite${suffix}.test.ts`);
writeFileSync(outPath, output);
console.log(`Wrote ${outPath}`);

View File

@@ -0,0 +1,934 @@
// Tests generated from json5/json5-tests official test suite
// Expected values verified against json5@2.2.3 reference implementation
import { JSON5 } from "bun";
import { describe, expect, test } from "bun:test";
describe("arrays", () => {
test("empty array", () => {
const input: string = "[]";
const parsed = JSON5.parse(input);
const expected: any = [];
expect(parsed).toEqual(expected);
});
test("leading comma array (throws)", () => {
const input: string = "[\n ,null\n]";
expect(() => JSON5.parse(input)).toThrow("Unexpected token");
});
test("lone trailing comma array (throws)", () => {
const input: string = "[\n ,\n]";
expect(() => JSON5.parse(input)).toThrow("Unexpected token");
});
test("no comma array (throws)", () => {
const input: string = "[\n true\n false\n]";
expect(() => JSON5.parse(input)).toThrow("Expected ','");
});
test("regular array", () => {
const input: string = "[\n true,\n false,\n null\n]";
const parsed = JSON5.parse(input);
const expected: any = [true, false, null];
expect(parsed).toEqual(expected);
});
test("trailing comma array", () => {
const input: string = "[\n null,\n]";
const parsed = JSON5.parse(input);
const expected: any = [null];
expect(parsed).toEqual(expected);
});
});
describe("comments", () => {
test("block comment following array element", () => {
const input: string = "[\n false\n /*\n true\n */\n]";
const parsed = JSON5.parse(input);
const expected: any = [false];
expect(parsed).toEqual(expected);
});
test("block comment following top level value", () => {
const input: string = "null\n/*\n Some non-comment top-level value is needed;\n we use null above.\n*/";
const parsed = JSON5.parse(input);
const expected: any = null;
expect(parsed).toEqual(expected);
});
test("block comment in string", () => {
const input: string = '"This /* block comment */ isn\'t really a block comment."';
const parsed = JSON5.parse(input);
const expected: any = "This /* block comment */ isn't really a block comment.";
expect(parsed).toEqual(expected);
});
test("block comment preceding top level value", () => {
const input: string = "/*\n Some non-comment top-level value is needed;\n we use null below.\n*/\nnull";
const parsed = JSON5.parse(input);
const expected: any = null;
expect(parsed).toEqual(expected);
});
test("block comment with asterisks", () => {
const input: string =
"/**\n * This is a JavaDoc-like block comment.\n * It contains asterisks inside of it.\n * It might also be closed with multiple asterisks.\n * Like this:\n **/\ntrue";
const parsed = JSON5.parse(input);
const expected: any = true;
expect(parsed).toEqual(expected);
});
test("inline comment following array element", () => {
const input: string = "[\n false // true\n]";
const parsed = JSON5.parse(input);
const expected: any = [false];
expect(parsed).toEqual(expected);
});
test("inline comment following top level value", () => {
const input: string = "null // Some non-comment top-level value is needed; we use null here.";
const parsed = JSON5.parse(input);
const expected: any = null;
expect(parsed).toEqual(expected);
});
test("inline comment in string", () => {
const input: string = '"This inline comment // isn\'t really an inline comment."';
const parsed = JSON5.parse(input);
const expected: any = "This inline comment // isn't really an inline comment.";
expect(parsed).toEqual(expected);
});
test("inline comment preceding top level value", () => {
const input: string = "// Some non-comment top-level value is needed; we use null below.\nnull";
const parsed = JSON5.parse(input);
const expected: any = null;
expect(parsed).toEqual(expected);
});
test("top level block comment (throws)", () => {
const input: string = "/*\n This should fail;\n comments cannot be the only top-level value.\n*/";
expect(() => JSON5.parse(input)).toThrow("Unexpected end of input");
});
test("top level inline comment (throws)", () => {
const input: string = "// This should fail; comments cannot be the only top-level value.";
expect(() => JSON5.parse(input)).toThrow("Unexpected end of input");
});
test("unterminated block comment (throws)", () => {
const input: string =
"true\n/*\n This block comment doesn't terminate.\n There was a legitimate value before this,\n but this is still invalid JS/JSON5.\n";
expect(() => JSON5.parse(input)).toThrow("Unterminated multi-line comment");
});
});
describe("misc", () => {
test("empty (throws)", () => {
const input: string = "";
expect(() => JSON5.parse(input)).toThrow("Unexpected end of input");
});
test("npm package", () => {
const input: string =
'{\n "name": "npm",\n "publishConfig": {\n "proprietary-attribs": false\n },\n "description": "A package manager for node",\n "keywords": [\n "package manager",\n "modules",\n "install",\n "package.json"\n ],\n "version": "1.1.22",\n "preferGlobal": true,\n "config": {\n "publishtest": false\n },\n "homepage": "http://npmjs.org/",\n "author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",\n "repository": {\n "type": "git",\n "url": "https://github.com/isaacs/npm"\n },\n "bugs": {\n "email": "npm-@googlegroups.com",\n "url": "http://github.com/isaacs/npm/issues"\n },\n "directories": {\n "doc": "./doc",\n "man": "./man",\n "lib": "./lib",\n "bin": "./bin"\n },\n "main": "./lib/npm.js",\n "bin": "./bin/npm-cli.js",\n "dependencies": {\n "semver": "~1.0.14",\n "ini": "1",\n "slide": "1",\n "abbrev": "1",\n "graceful-fs": "~1.1.1",\n "minimatch": "~0.2",\n "nopt": "1",\n "node-uuid": "~1.3",\n "proto-list": "1",\n "rimraf": "2",\n "request": "~2.9",\n "which": "1",\n "tar": "~0.1.12",\n "fstream": "~0.1.17",\n "block-stream": "*",\n "inherits": "1",\n "mkdirp": "0.3",\n "read": "0",\n "lru-cache": "1",\n "node-gyp": "~0.4.1",\n "fstream-npm": "0 >=0.0.5",\n "uid-number": "0",\n "archy": "0",\n "chownr": "0"\n },\n "bundleDependencies": [\n "slide",\n "ini",\n "semver",\n "abbrev",\n "graceful-fs",\n "minimatch",\n "nopt",\n "node-uuid",\n "rimraf",\n "request",\n "proto-list",\n "which",\n "tar",\n "fstream",\n "block-stream",\n "inherits",\n "mkdirp",\n "read",\n "lru-cache",\n "node-gyp",\n "fstream-npm",\n "uid-number",\n "archy",\n "chownr"\n ],\n "devDependencies": {\n "ronn": "https://github.com/isaacs/ronnjs/tarball/master"\n },\n "engines": {\n "node": "0.6 || 0.7 || 0.8",\n "npm": "1"\n },\n "scripts": {\n "test": "node ./test/run.js",\n "prepublish": "npm prune; rm -rf node_modules/*/{test,example,bench}*; make -j4 doc",\n "dumpconf": "env | grep npm | sort | uniq"\n },\n "licenses": [\n {\n "type": "MIT +no-false-attribs",\n "url": "http://github.com/isaacs/npm/raw/master/LICENSE"\n }\n ]\n}\n';
const parsed = JSON5.parse(input);
const expected: any = {
name: "npm",
publishConfig: { "proprietary-attribs": false },
description: "A package manager for node",
keywords: ["package manager", "modules", "install", "package.json"],
version: "1.1.22",
preferGlobal: true,
config: { publishtest: false },
homepage: "http://npmjs.org/",
author: "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",
repository: { type: "git", url: "https://github.com/isaacs/npm" },
bugs: { email: "npm-@googlegroups.com", url: "http://github.com/isaacs/npm/issues" },
directories: { doc: "./doc", man: "./man", lib: "./lib", bin: "./bin" },
main: "./lib/npm.js",
bin: "./bin/npm-cli.js",
dependencies: {
semver: "~1.0.14",
ini: "1",
slide: "1",
abbrev: "1",
"graceful-fs": "~1.1.1",
minimatch: "~0.2",
nopt: "1",
"node-uuid": "~1.3",
"proto-list": "1",
rimraf: "2",
request: "~2.9",
which: "1",
tar: "~0.1.12",
fstream: "~0.1.17",
"block-stream": "*",
inherits: "1",
mkdirp: "0.3",
read: "0",
"lru-cache": "1",
"node-gyp": "~0.4.1",
"fstream-npm": "0 >=0.0.5",
"uid-number": "0",
archy: "0",
chownr: "0",
},
bundleDependencies: [
"slide",
"ini",
"semver",
"abbrev",
"graceful-fs",
"minimatch",
"nopt",
"node-uuid",
"rimraf",
"request",
"proto-list",
"which",
"tar",
"fstream",
"block-stream",
"inherits",
"mkdirp",
"read",
"lru-cache",
"node-gyp",
"fstream-npm",
"uid-number",
"archy",
"chownr",
],
devDependencies: { ronn: "https://github.com/isaacs/ronnjs/tarball/master" },
engines: { node: "0.6 || 0.7 || 0.8", npm: "1" },
scripts: {
test: "node ./test/run.js",
prepublish: "npm prune; rm -rf node_modules/*/{test,example,bench}*; make -j4 doc",
dumpconf: "env | grep npm | sort | uniq",
},
licenses: [
{
type: "MIT +no-false-attribs",
url: "http://github.com/isaacs/npm/raw/master/LICENSE",
},
],
};
expect(parsed).toEqual(expected);
});
test("npm package", () => {
const input: string =
"{\n name: 'npm',\n publishConfig: {\n 'proprietary-attribs': false,\n },\n description: 'A package manager for node',\n keywords: [\n 'package manager',\n 'modules',\n 'install',\n 'package.json',\n ],\n version: '1.1.22',\n preferGlobal: true,\n config: {\n publishtest: false,\n },\n homepage: 'http://npmjs.org/',\n author: 'Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)',\n repository: {\n type: 'git',\n url: 'https://github.com/isaacs/npm',\n },\n bugs: {\n email: 'npm-@googlegroups.com',\n url: 'http://github.com/isaacs/npm/issues',\n },\n directories: {\n doc: './doc',\n man: './man',\n lib: './lib',\n bin: './bin',\n },\n main: './lib/npm.js',\n bin: './bin/npm-cli.js',\n dependencies: {\n semver: '~1.0.14',\n ini: '1',\n slide: '1',\n abbrev: '1',\n 'graceful-fs': '~1.1.1',\n minimatch: '~0.2',\n nopt: '1',\n 'node-uuid': '~1.3',\n 'proto-list': '1',\n rimraf: '2',\n request: '~2.9',\n which: '1',\n tar: '~0.1.12',\n fstream: '~0.1.17',\n 'block-stream': '*',\n inherits: '1',\n mkdirp: '0.3',\n read: '0',\n 'lru-cache': '1',\n 'node-gyp': '~0.4.1',\n 'fstream-npm': '0 >=0.0.5',\n 'uid-number': '0',\n archy: '0',\n chownr: '0',\n },\n bundleDependencies: [\n 'slide',\n 'ini',\n 'semver',\n 'abbrev',\n 'graceful-fs',\n 'minimatch',\n 'nopt',\n 'node-uuid',\n 'rimraf',\n 'request',\n 'proto-list',\n 'which',\n 'tar',\n 'fstream',\n 'block-stream',\n 'inherits',\n 'mkdirp',\n 'read',\n 'lru-cache',\n 'node-gyp',\n 'fstream-npm',\n 'uid-number',\n 'archy',\n 'chownr',\n ],\n devDependencies: {\n ronn: 'https://github.com/isaacs/ronnjs/tarball/master',\n },\n engines: {\n node: '0.6 || 0.7 || 0.8',\n npm: '1',\n },\n scripts: {\n test: 'node ./test/run.js',\n prepublish: 'npm prune; rm -rf node_modules/*/{test,example,bench}*; make -j4 doc',\n dumpconf: 'env | grep npm | sort | uniq',\n },\n licenses: [\n {\n type: 'MIT +no-false-attribs',\n url: 'http://github.com/isaacs/npm/raw/master/LICENSE',\n },\n ],\n}\n";
const parsed = JSON5.parse(input);
const expected: any = {
name: "npm",
publishConfig: { "proprietary-attribs": false },
description: "A package manager for node",
keywords: ["package manager", "modules", "install", "package.json"],
version: "1.1.22",
preferGlobal: true,
config: { publishtest: false },
homepage: "http://npmjs.org/",
author: "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)",
repository: { type: "git", url: "https://github.com/isaacs/npm" },
bugs: { email: "npm-@googlegroups.com", url: "http://github.com/isaacs/npm/issues" },
directories: { doc: "./doc", man: "./man", lib: "./lib", bin: "./bin" },
main: "./lib/npm.js",
bin: "./bin/npm-cli.js",
dependencies: {
semver: "~1.0.14",
ini: "1",
slide: "1",
abbrev: "1",
"graceful-fs": "~1.1.1",
minimatch: "~0.2",
nopt: "1",
"node-uuid": "~1.3",
"proto-list": "1",
rimraf: "2",
request: "~2.9",
which: "1",
tar: "~0.1.12",
fstream: "~0.1.17",
"block-stream": "*",
inherits: "1",
mkdirp: "0.3",
read: "0",
"lru-cache": "1",
"node-gyp": "~0.4.1",
"fstream-npm": "0 >=0.0.5",
"uid-number": "0",
archy: "0",
chownr: "0",
},
bundleDependencies: [
"slide",
"ini",
"semver",
"abbrev",
"graceful-fs",
"minimatch",
"nopt",
"node-uuid",
"rimraf",
"request",
"proto-list",
"which",
"tar",
"fstream",
"block-stream",
"inherits",
"mkdirp",
"read",
"lru-cache",
"node-gyp",
"fstream-npm",
"uid-number",
"archy",
"chownr",
],
devDependencies: { ronn: "https://github.com/isaacs/ronnjs/tarball/master" },
engines: { node: "0.6 || 0.7 || 0.8", npm: "1" },
scripts: {
test: "node ./test/run.js",
prepublish: "npm prune; rm -rf node_modules/*/{test,example,bench}*; make -j4 doc",
dumpconf: "env | grep npm | sort | uniq",
},
licenses: [
{
type: "MIT +no-false-attribs",
url: "http://github.com/isaacs/npm/raw/master/LICENSE",
},
],
};
expect(parsed).toEqual(expected);
});
test("readme example", () => {
const input: string =
"{\n foo: 'bar',\n while: true,\n\n this: 'is a \\\nmulti-line string',\n\n // this is an inline comment\n here: 'is another', // inline comment\n\n /* this is a block comment\n that continues on another line */\n\n hex: 0xDEADbeef,\n half: .5,\n delta: +10,\n to: Infinity, // and beyond!\n\n finally: 'a trailing comma',\n oh: [\n \"we shouldn't forget\",\n 'arrays can have',\n 'trailing commas too',\n ],\n}\n";
const parsed = JSON5.parse(input);
const expected: any = {
foo: "bar",
while: true,
this: "is a multi-line string",
here: "is another",
hex: 3735928559,
half: 0.5,
delta: 10,
to: Infinity,
finally: "a trailing comma",
oh: ["we shouldn't forget", "arrays can have", "trailing commas too"],
};
expect(parsed).toEqual(expected);
});
test("valid whitespace", () => {
const input: string =
'{\n \f // An invalid form feed character (\\x0c) has been entered before this comment.\n // Be careful not to delete it.\n "a": true\n}\n';
const parsed = JSON5.parse(input);
const expected: any = { a: true };
expect(parsed).toEqual(expected);
});
});
describe("new-lines", () => {
test("comment cr", () => {
const input: string = "{\r // This comment is terminated with `\\r`.\r}\r";
const parsed = JSON5.parse(input);
const expected: any = {};
expect(parsed).toEqual(expected);
});
test("comment crlf", () => {
const input: string = "{\r\n // This comment is terminated with `\\r\\n`.\r\n}\r\n";
const parsed = JSON5.parse(input);
const expected: any = {};
expect(parsed).toEqual(expected);
});
test("comment lf", () => {
const input: string = "{\n // This comment is terminated with `\\n`.\n}\n";
const parsed = JSON5.parse(input);
const expected: any = {};
expect(parsed).toEqual(expected);
});
test("escaped cr", () => {
const input: string = "{\r // the following string contains an escaped `\\r`\r a: 'line 1 \\\rline 2'\r}\r";
const parsed = JSON5.parse(input);
const expected: any = { a: "line 1 line 2" };
expect(parsed).toEqual(expected);
});
test("escaped crlf", () => {
const input: string =
"{\r\n // the following string contains an escaped `\\r\\n`\r\n a: 'line 1 \\\r\nline 2'\r\n}\r\n";
const parsed = JSON5.parse(input);
const expected: any = { a: "line 1 line 2" };
expect(parsed).toEqual(expected);
});
test("escaped lf", () => {
const input: string = "{\n // the following string contains an escaped `\\n`\n a: 'line 1 \\\nline 2'\n}\n";
const parsed = JSON5.parse(input);
const expected: any = { a: "line 1 line 2" };
expect(parsed).toEqual(expected);
});
});
describe("numbers", () => {
test("float leading decimal point", () => {
const input: string = ".5\n";
const parsed = JSON5.parse(input);
const expected: any = 0.5;
expect(parsed).toEqual(expected);
});
test("float leading zero", () => {
const input: string = "0.5\n";
const parsed = JSON5.parse(input);
const expected: any = 0.5;
expect(parsed).toEqual(expected);
});
test("float trailing decimal point with integer exponent", () => {
const input: string = "5.e4\n";
const parsed = JSON5.parse(input);
const expected: any = 50000;
expect(parsed).toEqual(expected);
});
test("float trailing decimal point", () => {
const input: string = "5.\n";
const parsed = JSON5.parse(input);
const expected: any = 5;
expect(parsed).toEqual(expected);
});
test("float with integer exponent", () => {
const input: string = "1.2e3\n";
const parsed = JSON5.parse(input);
const expected: any = 1200;
expect(parsed).toEqual(expected);
});
test("float", () => {
const input: string = "1.2\n";
const parsed = JSON5.parse(input);
const expected: any = 1.2;
expect(parsed).toEqual(expected);
});
test("hexadecimal empty (throws)", () => {
const input: string = "0x\n";
expect(() => JSON5.parse(input)).toThrow("Invalid hex number");
});
test("hexadecimal lowercase letter", () => {
const input: string = "0xc8\n";
const parsed = JSON5.parse(input);
const expected: any = 200;
expect(parsed).toEqual(expected);
});
test("hexadecimal uppercase x", () => {
const input: string = "0XC8\n";
const parsed = JSON5.parse(input);
const expected: any = 200;
expect(parsed).toEqual(expected);
});
test("hexadecimal with integer exponent", () => {
const input: string = "0xc8e4\n";
const parsed = JSON5.parse(input);
const expected: any = 51428;
expect(parsed).toEqual(expected);
});
test("hexadecimal", () => {
const input: string = "0xC8\n";
const parsed = JSON5.parse(input);
const expected: any = 200;
expect(parsed).toEqual(expected);
});
test("infinity", () => {
const input: string = "Infinity\n";
const parsed = JSON5.parse(input);
const expected: any = Infinity;
expect(parsed).toEqual(expected);
});
test("integer with float exponent (throws)", () => {
const input: string = "1e2.3\n";
expect(() => JSON5.parse(input)).toThrow("Unexpected token after JSON5 value");
});
test("integer with hexadecimal exponent (throws)", () => {
const input: string = "1e0x4\n";
expect(() => JSON5.parse(input)).toThrow("Unexpected token after JSON5 value");
});
test("integer with integer exponent", () => {
const input: string = "2e23\n";
const parsed = JSON5.parse(input);
const expected: any = 2e23;
expect(parsed).toEqual(expected);
});
test("integer with negative float exponent (throws)", () => {
const input: string = "1e-2.3\n";
expect(() => JSON5.parse(input)).toThrow("Unexpected token after JSON5 value");
});
test("integer with negative hexadecimal exponent (throws)", () => {
const input: string = "1e-0x4\n";
expect(() => JSON5.parse(input)).toThrow("Unexpected token after JSON5 value");
});
test("integer with negative integer exponent", () => {
const input: string = "2e-23\n";
const parsed = JSON5.parse(input);
const expected: any = 2e-23;
expect(parsed).toEqual(expected);
});
test("integer with negative zero integer exponent", () => {
const input: string = "5e-0\n";
const parsed = JSON5.parse(input);
const expected: any = 5;
expect(parsed).toEqual(expected);
});
test("integer with positive float exponent (throws)", () => {
const input: string = "1e+2.3\n";
expect(() => JSON5.parse(input)).toThrow("Unexpected token after JSON5 value");
});
test("integer with positive hexadecimal exponent (throws)", () => {
const input: string = "1e+0x4\n";
expect(() => JSON5.parse(input)).toThrow("Unexpected token after JSON5 value");
});
test("integer with positive integer exponent", () => {
const input: string = "1e+2\n";
const parsed = JSON5.parse(input);
const expected: any = 100;
expect(parsed).toEqual(expected);
});
test("integer with positive zero integer exponent", () => {
const input: string = "5e+0\n";
const parsed = JSON5.parse(input);
const expected: any = 5;
expect(parsed).toEqual(expected);
});
test("integer with zero integer exponent", () => {
const input: string = "5e0\n";
const parsed = JSON5.parse(input);
const expected: any = 5;
expect(parsed).toEqual(expected);
});
test("integer", () => {
const input: string = "15\n";
const parsed = JSON5.parse(input);
const expected: any = 15;
expect(parsed).toEqual(expected);
});
test("lone decimal point (throws)", () => {
const input: string = ".\n";
expect(() => JSON5.parse(input)).toThrow("Invalid number");
});
test("nan", () => {
const input: string = "NaN\n";
const parsed = JSON5.parse(input);
expect(Number.isNaN(parsed)).toBe(true);
});
test("negative float leading decimal point", () => {
const input: string = "-.5\n";
const parsed = JSON5.parse(input);
const expected: any = -0.5;
expect(parsed).toEqual(expected);
});
test("negative float leading zero", () => {
const input: string = "-0.5\n";
const parsed = JSON5.parse(input);
const expected: any = -0.5;
expect(parsed).toEqual(expected);
});
test("negative float trailing decimal point", () => {
const input: string = "-5.\n";
const parsed = JSON5.parse(input);
const expected: any = -5;
expect(parsed).toEqual(expected);
});
test("negative float", () => {
const input: string = "-1.2\n";
const parsed = JSON5.parse(input);
const expected: any = -1.2;
expect(parsed).toEqual(expected);
});
test("negative hexadecimal", () => {
const input: string = "-0xC8\n";
const parsed = JSON5.parse(input);
const expected: any = -200;
expect(parsed).toEqual(expected);
});
test("negative infinity", () => {
const input: string = "-Infinity\n";
const parsed = JSON5.parse(input);
const expected: any = -Infinity;
expect(parsed).toEqual(expected);
});
test("negative integer", () => {
const input: string = "-15\n";
const parsed = JSON5.parse(input);
const expected: any = -15;
expect(parsed).toEqual(expected);
});
test("negative noctal (throws)", () => {
const input: string = "-098\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("negative octal (throws)", () => {
const input: string = "-0123\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("negative zero float leading decimal point", () => {
const input: string = "-.0\n";
const parsed = JSON5.parse(input);
const expected: any = -0;
expect(parsed).toEqual(expected);
});
test("negative zero float trailing decimal point", () => {
const input: string = "-0.\n";
const parsed = JSON5.parse(input);
const expected: any = -0;
expect(parsed).toEqual(expected);
});
test("negative zero float", () => {
const input: string = "-0.0\n";
const parsed = JSON5.parse(input);
const expected: any = -0;
expect(parsed).toEqual(expected);
});
test("negative zero hexadecimal", () => {
const input: string = "-0x0\n";
const parsed = JSON5.parse(input);
const expected: any = -0;
expect(parsed).toEqual(expected);
});
test("negative zero integer", () => {
const input: string = "-0\n";
const parsed = JSON5.parse(input);
const expected: any = -0;
expect(parsed).toEqual(expected);
});
test("negative zero octal (throws)", () => {
const input: string = "-00\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("noctal with leading octal digit (throws)", () => {
const input: string = "0780\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("noctal (throws)", () => {
const input: string = "080\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("octal (throws)", () => {
const input: string = "010\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("positive float leading decimal point", () => {
const input: string = "+.5\n";
const parsed = JSON5.parse(input);
const expected: any = 0.5;
expect(parsed).toEqual(expected);
});
test("positive float leading zero", () => {
const input: string = "+0.5\n";
const parsed = JSON5.parse(input);
const expected: any = 0.5;
expect(parsed).toEqual(expected);
});
test("positive float trailing decimal point", () => {
const input: string = "+5.\n";
const parsed = JSON5.parse(input);
const expected: any = 5;
expect(parsed).toEqual(expected);
});
test("positive float", () => {
const input: string = "+1.2\n";
const parsed = JSON5.parse(input);
const expected: any = 1.2;
expect(parsed).toEqual(expected);
});
test("positive hexadecimal", () => {
const input: string = "+0xC8\n";
const parsed = JSON5.parse(input);
const expected: any = 200;
expect(parsed).toEqual(expected);
});
test("positive infinity", () => {
const input: string = "+Infinity\n";
const parsed = JSON5.parse(input);
const expected: any = Infinity;
expect(parsed).toEqual(expected);
});
test("positive integer", () => {
const input: string = "+15\n";
const parsed = JSON5.parse(input);
const expected: any = 15;
expect(parsed).toEqual(expected);
});
test("positive noctal (throws)", () => {
const input: string = "+098\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("positive octal (throws)", () => {
const input: string = "+0123\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("positive zero float leading decimal point", () => {
const input: string = "+.0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("positive zero float trailing decimal point", () => {
const input: string = "+0.\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("positive zero float", () => {
const input: string = "+0.0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("positive zero hexadecimal", () => {
const input: string = "+0x0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("positive zero integer", () => {
const input: string = "+0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("positive zero octal (throws)", () => {
const input: string = "+00\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
test("zero float leading decimal point", () => {
const input: string = ".0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("zero float trailing decimal point", () => {
const input: string = "0.\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("zero float", () => {
const input: string = "0.0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("zero hexadecimal", () => {
const input: string = "0x0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("zero integer with integer exponent", () => {
const input: string = "0e23\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("zero integer", () => {
const input: string = "0\n";
const parsed = JSON5.parse(input);
const expected: any = 0;
expect(parsed).toEqual(expected);
});
test("zero octal (throws)", () => {
const input: string = "00\n";
expect(() => JSON5.parse(input)).toThrow("Leading zeros are not allowed in JSON5");
});
});
describe("objects", () => {
test("duplicate keys", () => {
const input: string = '{\n "a": true,\n "a": false\n}\n';
const parsed = JSON5.parse(input);
const expected: any = { a: false };
expect(parsed).toEqual(expected);
});
test("empty object", () => {
const input: string = "{}";
const parsed = JSON5.parse(input);
const expected: any = {};
expect(parsed).toEqual(expected);
});
test("illegal unquoted key number (throws)", () => {
const input: string = '{\n 10twenty: "ten twenty"\n}';
expect(() => JSON5.parse(input)).toThrow("Invalid identifier start character");
});
test("illegal unquoted key symbol (throws)", () => {
const input: string = '{\n multi-word: "multi-word"\n}';
expect(() => JSON5.parse(input)).toThrow("Unexpected character");
});
test("leading comma object (throws)", () => {
const input: string = '{\n ,"foo": "bar"\n}';
expect(() => JSON5.parse(input)).toThrow("Invalid identifier start character");
});
test("lone trailing comma object (throws)", () => {
const input: string = "{\n ,\n}";
expect(() => JSON5.parse(input)).toThrow("Invalid identifier start character");
});
test("no comma object (throws)", () => {
const input: string = '{\n "foo": "bar"\n "hello": "world"\n}';
expect(() => JSON5.parse(input)).toThrow("Expected ','");
});
test("reserved unquoted key", () => {
const input: string = "{\n while: true\n}";
const parsed = JSON5.parse(input);
const expected: any = { while: true };
expect(parsed).toEqual(expected);
});
test("single quoted key", () => {
const input: string = "{\n 'hello': \"world\"\n}";
const parsed = JSON5.parse(input);
const expected: any = { hello: "world" };
expect(parsed).toEqual(expected);
});
test("trailing comma object", () => {
const input: string = '{\n "foo": "bar",\n}';
const parsed = JSON5.parse(input);
const expected: any = { foo: "bar" };
expect(parsed).toEqual(expected);
});
test("unquoted keys", () => {
const input: string =
'{\n hello: "world",\n _: "underscore",\n $: "dollar sign",\n one1: "numerals",\n _$_: "multiple symbols",\n $_$hello123world_$_: "mixed"\n}';
const parsed = JSON5.parse(input);
const expected: any = {
hello: "world",
_: "underscore",
$: "dollar sign",
one1: "numerals",
_$_: "multiple symbols",
$_$hello123world_$_: "mixed",
};
expect(parsed).toEqual(expected);
});
});
describe("strings", () => {
test("escaped single quoted string", () => {
const input: string = "'I can\\'t wait'";
const parsed = JSON5.parse(input);
const expected: any = "I can't wait";
expect(parsed).toEqual(expected);
});
test("multi line string", () => {
const input: string = "'hello\\\n world'";
const parsed = JSON5.parse(input);
const expected: any = "hello world";
expect(parsed).toEqual(expected);
});
test("single quoted string", () => {
const input: string = "'hello world'";
const parsed = JSON5.parse(input);
const expected: any = "hello world";
expect(parsed).toEqual(expected);
});
test("unescaped multi line string (throws)", () => {
const input: string = '"foo\nbar"\n';
expect(() => JSON5.parse(input)).toThrow("Unterminated string");
});
});
describe("todo", () => {
test("unicode escaped unquoted key", () => {
const input: string = '{\n sig\\u03A3ma: "the sum of all things"\n}';
const parsed = JSON5.parse(input);
const expected: any = { "sigΣma": "the sum of all things" };
expect(parsed).toEqual(expected);
});
test("unicode unquoted key", () => {
const input: string = '{\n ümlåût: "that\'s not really an ümlaüt, but this is"\n}';
const parsed = JSON5.parse(input);
const expected: any = { "ümlåût": "that's not really an ümlaüt, but this is" };
expect(parsed).toEqual(expected);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
// A JSON5 file with just null
null

View File

@@ -0,0 +1,32 @@
{
// Framework configuration
framework: "next",
bundle: {
packages: {
"@emotion/react": true,
},
},
array: [
{
entry_one: "one",
entry_two: "two",
},
{
entry_one: "three",
nested: [
{
entry_one: "four",
},
],
},
],
dev: {
one: {
two: {
three: 4,
},
},
foo: 123,
'foo.bar': "baz",
},
}

View File

@@ -0,0 +1,8 @@
{
framework: "next",
bundle: {
packages: {
"@emotion/react": true,
},
},
}

View File

@@ -0,0 +1,63 @@
import { expect, it } from "bun:test";
import emptyJson5 from "./json5-empty.json5";
import json5FromCustomTypeAttribute from "./json5-fixture.json5.txt" with { type: "json5" };
const expectedJson5Fixture = {
framework: "next",
bundle: {
packages: {
"@emotion/react": true,
},
},
array: [
{
entry_one: "one",
entry_two: "two",
},
{
entry_one: "three",
nested: [
{
entry_one: "four",
},
],
},
],
dev: {
one: {
two: {
three: 4,
},
},
foo: 123,
"foo.bar": "baz",
},
};
const expectedSmallFixture = {
framework: "next",
bundle: {
packages: {
"@emotion/react": true,
},
},
};
it("via dynamic import", async () => {
const json5 = (await import("./json5-fixture.json5")).default;
expect(json5).toEqual(expectedJson5Fixture);
});
it("via import type json5", () => {
expect(json5FromCustomTypeAttribute).toEqual(expectedSmallFixture);
});
it("via dynamic import with type attribute", async () => {
delete require.cache[require.resolve("./json5-fixture.json5.txt")];
const json5 = (await import("./json5-fixture.json5.txt", { with: { type: "json5" } })).default;
expect(json5).toEqual(expectedSmallFixture);
});
it("null value via import statement", () => {
expect(emptyJson5).toBe(null);
});

View File

@@ -1024,6 +1024,33 @@ config:
expect(YAML.stringify([])).toBe("[]");
});
test("space parameter with Infinity/NaN/large numbers", () => {
expect(YAML.stringify({ a: 1 }, null, Infinity)).toEqual(YAML.stringify({ a: 1 }, null, 10));
expect(YAML.stringify({ a: 1 }, null, -Infinity)).toEqual(YAML.stringify({ a: 1 }));
expect(YAML.stringify({ a: 1 }, null, NaN)).toEqual(YAML.stringify({ a: 1 }));
expect(YAML.stringify({ a: 1 }, null, 100)).toEqual(YAML.stringify({ a: 1 }, null, 10));
expect(YAML.stringify({ a: 1 }, null, 2147483648)).toEqual(YAML.stringify({ a: 1 }, null, 10));
expect(YAML.stringify({ a: 1 }, null, 3e9)).toEqual(YAML.stringify({ a: 1 }, null, 10));
});
test("space parameter with boxed Number", () => {
expect(YAML.stringify({ a: 1 }, null, new Number(2) as any)).toEqual(YAML.stringify({ a: 1 }, null, 2));
expect(YAML.stringify({ a: 1 }, null, new Number(0) as any)).toEqual(YAML.stringify({ a: 1 }, null, 0));
expect(YAML.stringify({ a: 1 }, null, new Number(-1) as any)).toEqual(YAML.stringify({ a: 1 }, null, -1));
expect(YAML.stringify({ a: 1 }, null, new Number(Infinity) as any)).toEqual(YAML.stringify({ a: 1 }, null, 10));
expect(YAML.stringify({ a: 1 }, null, new Number(NaN) as any)).toEqual(YAML.stringify({ a: 1 }, null, 0));
});
test("space parameter with boxed String", () => {
expect(YAML.stringify({ a: 1 }, null, new String("\t") as any)).toEqual(YAML.stringify({ a: 1 }, null, "\t"));
expect(YAML.stringify({ a: 1 }, null, new String("") as any)).toEqual(YAML.stringify({ a: 1 }, null, ""));
});
test("all-undefined properties produces empty object", () => {
expect(YAML.stringify({ a: undefined, b: undefined }, null, 2)).toBe("{}");
expect(YAML.stringify({ a: () => {}, b: () => {} }, null, 2)).toBe("{}");
});
test("stringifies simple arrays", () => {
expect(YAML.stringify([1, 2, 3], null, 2)).toBe("- 1\n- 2\n- 3");
expect(YAML.stringify(["a", "b", "c"], null, 2)).toBe("- a\n- b\n- c");