Files
bun.sh/test/js/bun/json5/json5.test.ts
Dylan Conway b59c77a6e7 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>
2026-01-26 10:52:35 -08:00

1574 lines
57 KiB
TypeScript

// Additional tests for features not covered by the official json5-tests suite.
// Expected values verified against json5@2.2.3 reference implementation.
import { JSON5 } from "bun";
import { describe, expect, test } from "bun:test";
describe("escape sequences", () => {
test("\\v vertical tab", () => {
const input: string = '"hello\\vworld"';
const parsed = JSON5.parse(input);
const expected: any = "hello\x0Bworld";
expect(parsed).toEqual(expected);
});
test("\\0 null character", () => {
const input: string = '"hello\\0world"';
const parsed = JSON5.parse(input);
const expected: any = "hello\x00world";
expect(parsed).toEqual(expected);
});
test("\\0 followed by non-digit", () => {
const input: string = '"\\0a"';
const parsed = JSON5.parse(input);
const expected: any = "\x00a";
expect(parsed).toEqual(expected);
});
test("\\0 followed by digit throws", () => {
expect(() => JSON5.parse('"\\01"')).toThrow("Octal escape sequences are not allowed in JSON5");
expect(() => JSON5.parse('"\\09"')).toThrow("Octal escape sequences are not allowed in JSON5");
});
test("\\1 through \\9 throw", () => {
for (let i = 1; i <= 9; i++) {
expect(() => JSON5.parse(`"\\${i}"`)).toThrow("Octal escape sequences are not allowed in JSON5");
}
});
test("\\xHH hex escape", () => {
const input: string = '"\\x41\\x42\\x43"';
const parsed = JSON5.parse(input);
const expected: any = "ABC";
expect(parsed).toEqual(expected);
});
test("\\xHH hex escape lowercase", () => {
const input: string = '"\\x61"';
const parsed = JSON5.parse(input);
const expected: any = "a";
expect(parsed).toEqual(expected);
});
test("\\xHH hex escape high byte", () => {
const input: string = '"\\xff"';
const parsed = JSON5.parse(input);
const expected: any = "\xFF";
expect(parsed).toEqual(expected);
});
test("\\xHH hex escape null", () => {
const input: string = '"\\x00"';
const parsed = JSON5.parse(input);
const expected: any = "\x00";
expect(parsed).toEqual(expected);
});
test("\\x with insufficient hex digits throws", () => {
expect(() => JSON5.parse('"\\xG0"')).toThrow("Invalid hex escape");
expect(() => JSON5.parse('"\\x0"')).toThrow("Invalid hex escape");
expect(() => JSON5.parse('"\\x"')).toThrow("Invalid hex escape");
});
test("\\uHHHH unicode escape A", () => {
const input: string = '"\\u0041"';
const parsed = JSON5.parse(input);
const expected: any = "A";
expect(parsed).toEqual(expected);
});
test("\\uHHHH unicode escape e-acute", () => {
const input: string = '"\\u00e9"';
const parsed = JSON5.parse(input);
const expected: any = "é";
expect(parsed).toEqual(expected);
});
test("\\uHHHH unicode escape CJK", () => {
const input: string = '"\\u4e16\\u754c"';
const parsed = JSON5.parse(input);
const expected: any = "世界";
expect(parsed).toEqual(expected);
});
test("\\u with insufficient hex digits throws", () => {
expect(() => JSON5.parse('"\\u041"')).toThrow("Invalid unicode escape: expected 4 hex digits");
expect(() => JSON5.parse('"\\u41"')).toThrow("Invalid unicode escape: expected 4 hex digits");
expect(() => JSON5.parse('"\\u"')).toThrow("Invalid unicode escape: expected 4 hex digits");
});
test("surrogate pairs", () => {
const input: string = '"\\uD83C\\uDFBC"';
const parsed = JSON5.parse(input);
const expected: any = "🎼";
expect(parsed).toEqual(expected);
});
test("identity escapes", () => {
const input: string = '"\\A\\C\\/\\D\\C"';
const parsed = JSON5.parse(input);
const expected: any = "AC/DC";
expect(parsed).toEqual(expected);
});
test("identity escape single char", () => {
const input: string = '"\\q"';
const parsed = JSON5.parse(input);
const expected: any = "q";
expect(parsed).toEqual(expected);
});
test("standard escape sequences", () => {
expect(JSON5.parse('"\\b"')).toEqual("\b");
expect(JSON5.parse('"\\f"')).toEqual("\f");
expect(JSON5.parse('"\\n"')).toEqual("\n");
expect(JSON5.parse('"\\r"')).toEqual("\r");
expect(JSON5.parse('"\\t"')).toEqual("\t");
expect(JSON5.parse('"\\\\"')).toEqual("\\");
expect(JSON5.parse('"\\""')).toEqual('"');
});
test("single quote escapes", () => {
const input: string = "'\\''";
const parsed = JSON5.parse(input);
const expected: any = "'";
expect(parsed).toEqual(expected);
});
test("line continuation with LF", () => {
const input: string = '"line1\\\nline2"';
const parsed = JSON5.parse(input);
const expected: any = "line1line2";
expect(parsed).toEqual(expected);
});
test("line continuation with CRLF", () => {
const input: string = '"line1\\\r\nline2"';
const parsed = JSON5.parse(input);
const expected: any = "line1line2";
expect(parsed).toEqual(expected);
});
test("line continuation with CR only", () => {
const input: string = '"line1\\\rline2"';
const parsed = JSON5.parse(input);
const expected: any = "line1line2";
expect(parsed).toEqual(expected);
});
test("U+2028 allowed unescaped in strings", () => {
const input: string = '"hello\u2028world"';
const parsed = JSON5.parse(input);
const expected: any = "hello\u2028world";
expect(parsed).toEqual(expected);
});
test("U+2029 allowed unescaped in strings", () => {
const input: string = '"hello\u2029world"';
const parsed = JSON5.parse(input);
const expected: any = "hello\u2029world";
expect(parsed).toEqual(expected);
});
});
describe("numbers - additional", () => {
test("+NaN", () => {
const input: string = "+NaN";
const parsed = JSON5.parse(input);
expect(Number.isNaN(parsed)).toBe(true);
});
test("-NaN", () => {
const input: string = "-NaN";
const parsed = JSON5.parse(input);
expect(Number.isNaN(parsed)).toBe(true);
});
test("+Infinity", () => {
const input: string = "+Infinity";
const parsed = JSON5.parse(input);
const expected: any = Infinity;
expect(parsed).toEqual(expected);
});
test("hex uppercase letters", () => {
const input: string = "0xDEADBEEF";
const parsed = JSON5.parse(input);
const expected: any = 0xdeadbeef;
expect(parsed).toEqual(expected);
});
test("hex mixed case", () => {
const input: string = "0xDeAdBeEf";
const parsed = JSON5.parse(input);
const expected: any = 0xdeadbeef;
expect(parsed).toEqual(expected);
});
test("trailing decimal with exponent", () => {
const input: string = "5.e2";
const parsed = JSON5.parse(input);
const expected: any = 500;
expect(parsed).toEqual(expected);
});
test("leading decimal with exponent", () => {
const input: string = ".5e2";
const parsed = JSON5.parse(input);
const expected: any = 50;
expect(parsed).toEqual(expected);
});
test("negative zero", () => {
expect(Object.is(JSON5.parse("-0"), -0)).toBe(true);
expect(Object.is(JSON5.parse("-0.0"), -0)).toBe(true);
});
test("leading zeros throw", () => {
expect(() => JSON5.parse("00")).toThrow("Leading zeros are not allowed in JSON5");
expect(() => JSON5.parse("01")).toThrow("Leading zeros are not allowed in JSON5");
expect(() => JSON5.parse("007")).toThrow("Leading zeros are not allowed in JSON5");
expect(() => JSON5.parse("-00")).toThrow("Leading zeros are not allowed in JSON5");
expect(() => JSON5.parse("+01")).toThrow("Leading zeros are not allowed in JSON5");
});
test("lone decimal point throws", () => {
expect(() => JSON5.parse(".")).toThrow("Invalid number");
expect(() => JSON5.parse("+.")).toThrow("Invalid number");
expect(() => JSON5.parse("-.")).toThrow("Invalid number");
});
test("hex with no digits throws", () => {
expect(() => JSON5.parse("0x")).toThrow("Invalid hex number");
expect(() => JSON5.parse("0X")).toThrow("Invalid hex number");
});
test("large hex number", () => {
const input: string = "0xFFFFFFFF";
const parsed = JSON5.parse(input);
const expected: any = 4294967295;
expect(parsed).toEqual(expected);
});
test("hex number exceeding i64 but fitting u64", () => {
// 0x8000000000000000 = 2^63, overflows i64 but fits u64
expect(JSON5.parse("0x8000000000000000")).toEqual(9223372036854775808);
// 0xFFFFFFFFFFFFFFFF = u64 max
expect(JSON5.parse("0xFFFFFFFFFFFFFFFF")).toEqual(18446744073709551615);
});
});
describe("objects - additional", () => {
test("all reserved word keys", () => {
const input: string = "{null: 1, true: 2, false: 3, if: 4, for: 5, class: 6, return: 7}";
const parsed = JSON5.parse(input);
const expected: any = { null: 1, true: 2, false: 3, if: 4, for: 5, class: 6, return: 7 };
expect(parsed).toEqual(expected);
});
test("nested objects with unquoted keys", () => {
const input: string = "{a: {b: {c: 'deep'}}}";
const parsed = JSON5.parse(input);
const expected: any = { a: { b: { c: "deep" } } };
expect(parsed).toEqual(expected);
});
test("mixed quoted and unquoted keys", () => {
const input: string = `{unquoted: 1, "double": 2, 'single': 3}`;
const parsed = JSON5.parse(input);
const expected: any = { unquoted: 1, double: 2, single: 3 };
expect(parsed).toEqual(expected);
});
test("key starting with $", () => {
const input: string = "{$key: 'value'}";
const parsed = JSON5.parse(input);
const expected: any = { $key: "value" };
expect(parsed).toEqual(expected);
});
test("key starting with _", () => {
const input: string = "{_private: true}";
const parsed = JSON5.parse(input);
const expected: any = { _private: true };
expect(parsed).toEqual(expected);
});
test("key with digits after first char", () => {
const input: string = "{a1b2c3: 'mixed'}";
const parsed = JSON5.parse(input);
const expected: any = { a1b2c3: "mixed" };
expect(parsed).toEqual(expected);
});
test("empty object", () => {
expect(JSON5.parse("{}")).toEqual({});
});
test("empty object with whitespace", () => {
expect(JSON5.parse("{ }")).toEqual({});
});
test("empty object with comment", () => {
const input: string = "{ /* empty */ }";
const parsed = JSON5.parse(input);
const expected: any = {};
expect(parsed).toEqual(expected);
});
test("keys cannot start with a digit (throws)", () => {
expect(() => JSON5.parse("{1key: true}")).toThrow("Invalid identifier start character");
});
});
describe("arrays - additional", () => {
test("empty array", () => {
expect(JSON5.parse("[]")).toEqual([]);
});
test("empty array with whitespace", () => {
expect(JSON5.parse("[ ]")).toEqual([]);
});
test("empty array with comment", () => {
expect(JSON5.parse("[ /* empty */ ]")).toEqual([]);
});
test("nested arrays", () => {
const input: string = "[[1, 2], [3, 4], [[5]]]";
const parsed = JSON5.parse(input);
const expected: any = [[1, 2], [3, 4], [[5]]];
expect(parsed).toEqual(expected);
});
test("mixed types in array", () => {
const input: string = "[1, 'two', true, null, {a: 3}, [4]]";
const parsed = JSON5.parse(input);
const expected: any = [1, "two", true, null, { a: 3 }, [4]];
expect(parsed).toEqual(expected);
});
test("array with comments between elements", () => {
const input: string = "[1, /* comment */ 2, // another\n3]";
const parsed = JSON5.parse(input);
const expected: any = [1, 2, 3];
expect(parsed).toEqual(expected);
});
test("double trailing comma throws", () => {
expect(() => JSON5.parse("[1,,]")).toThrow("Unexpected token");
});
test("leading comma throws", () => {
expect(() => JSON5.parse("[,1]")).toThrow("Unexpected token");
});
test("lone comma throws", () => {
expect(() => JSON5.parse("[,]")).toThrow("Unexpected token");
});
});
describe("whitespace - additional", () => {
test("vertical tab as whitespace", () => {
const input: string = "\x0B42\x0B";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("form feed as whitespace", () => {
const input: string = "\x0C42\x0C";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("non-breaking space as whitespace", () => {
const input: string = "\u00A042\u00A0";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("BOM as whitespace", () => {
const input: string = "\uFEFF42";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("line separator as whitespace", () => {
const input: string = "\u202842\u2028";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("paragraph separator as whitespace", () => {
const input: string = "\u202942\u2029";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
});
describe("comments - additional", () => {
test("comment at end of file with no newline", () => {
const input: string = "42 // comment";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("multiple comments", () => {
const input: string = "/* a */ /* b */ 42 /* c */";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("nested block comment syntax is just content", () => {
const input: string = "/* /* not nested */ 42";
const parsed = JSON5.parse(input);
const expected: any = 42;
expect(parsed).toEqual(expected);
});
test("comment only (no value) throws", () => {
expect(() => JSON5.parse("// comment")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("/* comment */")).toThrow("Unexpected end of input");
});
});
describe("error messages", () => {
test("throws SyntaxError instances", () => {
try {
JSON5.parse("invalid");
expect.unreachable();
} catch (e: any) {
expect(e).toBeInstanceOf(SyntaxError);
expect(e.message).toContain("Unexpected token");
}
});
// -- Unexpected end of input --
test("empty string", () => {
expect(() => JSON5.parse("")).toThrow("Unexpected end of input");
});
test("whitespace only", () => {
expect(() => JSON5.parse(" ")).toThrow("Unexpected end of input");
});
// -- Unexpected token after JSON5 value --
test("multiple top-level values", () => {
expect(() => JSON5.parse("1 2")).toThrow("Unexpected token after JSON5 value");
expect(() => JSON5.parse("true false")).toThrow("Unexpected token after JSON5 value");
expect(() => JSON5.parse("null null")).toThrow("Unexpected token after JSON5 value");
});
// -- Unexpected character --
test("unexpected character at top level", () => {
expect(() => JSON5.parse("@")).toThrow("Unexpected character");
expect(() => JSON5.parse("undefined")).toThrow("Unexpected token");
expect(() => JSON5.parse("{a: hello}")).toThrow("Unexpected token");
});
// -- Unterminated multi-line comment --
test("unterminated multi-line comment", () => {
expect(() => JSON5.parse("/* unterminated")).toThrow("Unterminated multi-line comment");
expect(() => JSON5.parse("/* no end")).toThrow("Unterminated multi-line comment");
expect(() => JSON5.parse("42 /* trailing")).toThrow("Unterminated multi-line comment");
});
// -- Unexpected end of input after sign --
test("sign with no value", () => {
expect(() => JSON5.parse("+")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("-")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("+ ")).toThrow("Unexpected character");
});
// -- Unexpected character after sign --
test("sign followed by invalid character", () => {
expect(() => JSON5.parse("+@")).toThrow("Unexpected character");
expect(() => JSON5.parse("-z")).toThrow("Unexpected character");
expect(() => JSON5.parse("+true")).toThrow("Unexpected character");
});
// -- Unexpected identifier --
test("incomplete true literal", () => {
expect(() => JSON5.parse("tru")).toThrow("Unexpected token");
expect(() => JSON5.parse("tr")).toThrow("Unexpected token");
});
test("true followed by identifier char", () => {
expect(() => JSON5.parse("truex")).toThrow("Unexpected token");
expect(() => JSON5.parse("truely")).toThrow("Unexpected token");
});
test("incomplete false literal", () => {
expect(() => JSON5.parse("fals")).toThrow("Unexpected token");
expect(() => JSON5.parse("fal")).toThrow("Unexpected token");
});
test("false followed by identifier char", () => {
expect(() => JSON5.parse("falsex")).toThrow("Unexpected token");
expect(() => JSON5.parse("falsely")).toThrow("Unexpected token");
});
test("incomplete null literal", () => {
expect(() => JSON5.parse("nul")).toThrow("Unexpected token");
expect(() => JSON5.parse("no")).toThrow("Unexpected token");
});
test("null followed by identifier char", () => {
expect(() => JSON5.parse("nullify")).toThrow("Unexpected token");
expect(() => JSON5.parse("nullx")).toThrow("Unexpected token");
});
test("NaN followed by identifier char", () => {
expect(() => JSON5.parse("NaNx")).toThrow("Unexpected token");
expect(() => JSON5.parse("NaNs")).toThrow("Unexpected token");
});
test("Infinity followed by identifier char", () => {
expect(() => JSON5.parse("Infinityx")).toThrow("Unexpected token");
expect(() => JSON5.parse("Infinitys")).toThrow("Unexpected token");
});
// -- Unexpected identifier (N/I not followed by keyword) --
test("N not followed by NaN", () => {
expect(() => JSON5.parse("N")).toThrow("Unexpected token");
expect(() => JSON5.parse("Na")).toThrow("Unexpected token");
expect(() => JSON5.parse("Nope")).toThrow("Unexpected token");
});
test("I not followed by Infinity", () => {
expect(() => JSON5.parse("I")).toThrow("Unexpected token");
expect(() => JSON5.parse("Inf")).toThrow("Unexpected token");
expect(() => JSON5.parse("Iffy")).toThrow("Unexpected token");
});
// -- Expected ':' after object key --
test("missing colon after object key", () => {
expect(() => JSON5.parse("{a 1}")).toThrow("Expected ':' after object key");
expect(() => JSON5.parse("{a}")).toThrow("Expected ':' after object key");
});
// -- Unterminated object --
test("unterminated object", () => {
expect(() => JSON5.parse("{a: 1")).toThrow("Unterminated object");
expect(() => JSON5.parse('{"a": 1')).toThrow("Unterminated object");
});
// -- Expected ',' --
test("missing comma in object", () => {
expect(() => JSON5.parse("{a: 1 b: 2}")).toThrow("Expected ','");
});
// -- Unexpected end of input in object key --
test("object key at EOF", () => {
expect(() => JSON5.parse("{")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("{a: 1,")).toThrow("Unexpected end of input");
});
// -- Invalid identifier start character --
test("invalid identifier start character in key", () => {
expect(() => JSON5.parse("{: 1}")).toThrow("Invalid identifier start character");
expect(() => JSON5.parse("{@key: 1}")).toThrow("Unexpected character");
});
// -- Expected 'u' after '\\' in identifier --
test("non-u escape in identifier key", () => {
expect(() => JSON5.parse("{\\x0041: 1}")).toThrow("Invalid unicode escape");
});
// -- Unterminated array --
test("unterminated array", () => {
expect(() => JSON5.parse("[1, 2")).toThrow("Unterminated array");
expect(() => JSON5.parse("[")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("[1")).toThrow("Unterminated array");
});
// -- Expected ',' --
test("missing comma in array", () => {
expect(() => JSON5.parse("[1 2]")).toThrow("Expected ','");
});
// -- Unterminated string --
test("unterminated string", () => {
expect(() => JSON5.parse('"hello')).toThrow("Unterminated string");
expect(() => JSON5.parse("'hello")).toThrow("Unterminated string");
expect(() => JSON5.parse("\"hello'")).toThrow("Unterminated string");
expect(() => JSON5.parse("'hello\"")).toThrow("Unterminated string");
});
test("newline in string", () => {
expect(() => JSON5.parse('"line\nbreak"')).toThrow("Unterminated string");
expect(() => JSON5.parse('"line\rbreak"')).toThrow("Unterminated string");
});
// -- Unexpected end of input in escape sequence --
test("escape sequence at end of input", () => {
expect(() => JSON5.parse('"\\')).toThrow("Unexpected end of input in escape sequence");
expect(() => JSON5.parse("'\\")).toThrow("Unexpected end of input in escape sequence");
});
// -- Octal escape sequences are not allowed in JSON5 --
test("octal escape sequences", () => {
expect(() => JSON5.parse('"\\01"')).toThrow("Octal escape sequences are not allowed in JSON5");
expect(() => JSON5.parse('"\\1"')).toThrow("Octal escape sequences are not allowed in JSON5");
expect(() => JSON5.parse('"\\9"')).toThrow("Octal escape sequences are not allowed in JSON5");
});
// -- Invalid hex escape --
test("invalid hex escape", () => {
expect(() => JSON5.parse('"\\xGG"')).toThrow("Invalid hex escape");
expect(() => JSON5.parse('"\\x0"')).toThrow("Invalid hex escape");
expect(() => JSON5.parse('"\\x"')).toThrow("Invalid hex escape");
});
// -- Invalid unicode escape: expected 4 hex digits --
test("invalid unicode escape", () => {
expect(() => JSON5.parse('"\\u"')).toThrow("Invalid unicode escape: expected 4 hex digits");
expect(() => JSON5.parse('"\\u041"')).toThrow("Invalid unicode escape: expected 4 hex digits");
expect(() => JSON5.parse('"\\uXXXX"')).toThrow("Invalid unicode escape: expected 4 hex digits");
});
// -- Leading zeros are not allowed in JSON5 --
test("leading zeros", () => {
expect(() => JSON5.parse("00")).toThrow("Leading zeros are not allowed in JSON5");
expect(() => JSON5.parse("01")).toThrow("Leading zeros are not allowed in JSON5");
});
// -- Invalid number: lone decimal point --
test("lone decimal point", () => {
expect(() => JSON5.parse(".")).toThrow("Invalid number");
});
// -- Invalid exponent in number --
test("invalid exponent", () => {
expect(() => JSON5.parse("1e")).toThrow("Invalid number");
expect(() => JSON5.parse("1e+")).toThrow("Invalid number");
expect(() => JSON5.parse("1E-")).toThrow("Invalid number");
expect(() => JSON5.parse("1ex")).toThrow("Invalid number");
});
// -- Expected hex digits after '0x' --
test("hex with no digits", () => {
expect(() => JSON5.parse("0x")).toThrow("Invalid hex number");
expect(() => JSON5.parse("0X")).toThrow("Invalid hex number");
expect(() => JSON5.parse("0xGG")).toThrow("Invalid hex number");
});
// -- Hex number too large --
test("hex number too large", () => {
expect(() => JSON5.parse("0xFFFFFFFFFFFFFFFFFF")).toThrow("Invalid hex number");
});
});
describe("stringify", () => {
test("stringifies null", () => {
expect(JSON5.stringify(null)).toEqual("null");
});
test("stringifies booleans", () => {
expect(JSON5.stringify(true)).toEqual("true");
expect(JSON5.stringify(false)).toEqual("false");
});
test("stringifies numbers", () => {
expect(JSON5.stringify(42)).toEqual("42");
expect(JSON5.stringify(3.14)).toEqual("3.14");
expect(JSON5.stringify(-1)).toEqual("-1");
expect(JSON5.stringify(0)).toEqual("0");
});
test("stringifies Infinity", () => {
expect(JSON5.stringify(Infinity)).toEqual("Infinity");
expect(JSON5.stringify(-Infinity)).toEqual("-Infinity");
});
test("stringifies NaN", () => {
expect(JSON5.stringify(NaN)).toEqual("NaN");
});
test("stringifies strings with single quotes", () => {
expect(JSON5.stringify("hello")).toEqual("'hello'");
});
test("escapes single quotes in strings", () => {
expect(JSON5.stringify("it's")).toEqual("'it\\'s'");
});
test("does not escape double quotes in strings", () => {
expect(JSON5.stringify('he said "hi"')).toEqual("'he said \"hi\"'");
});
test("escapes control characters in strings", () => {
expect(JSON5.stringify("line\nnew")).toEqual("'line\\nnew'");
expect(JSON5.stringify("tab\there")).toEqual("'tab\\there'");
expect(JSON5.stringify("back\\slash")).toEqual("'back\\\\slash'");
});
test("stringifies objects with unquoted keys", () => {
expect(JSON5.stringify({ a: 1, b: "two" })).toEqual("{a:1,b:'two'}");
});
test("quotes keys that are not valid identifiers", () => {
expect(JSON5.stringify({ "foo bar": 1 })).toEqual("{'foo bar':1}");
expect(JSON5.stringify({ "0key": 1 })).toEqual("{'0key':1}");
expect(JSON5.stringify({ "key-name": 1 })).toEqual("{'key-name':1}");
expect(JSON5.stringify({ "": 1 })).toEqual("{'':1}");
});
test("stringifies arrays", () => {
expect(JSON5.stringify([1, "two", true])).toEqual("[1,'two',true]");
});
test("stringifies nested structures", () => {
expect(JSON5.stringify({ a: [1, { b: 2 }] })).toEqual("{a:[1,{b:2}]}");
});
test("stringifies Infinity and NaN in objects and arrays", () => {
expect(JSON5.stringify({ x: Infinity, y: NaN })).toEqual("{x:Infinity,y:NaN}");
expect(JSON5.stringify([Infinity, -Infinity, NaN])).toEqual("[Infinity,-Infinity,NaN]");
});
test("replacer function throws", () => {
expect(() => JSON5.stringify({ a: 1 }, (key: string, value: any) => value)).toThrow(
"JSON5.stringify does not support the replacer argument",
);
});
test("replacer array throws", () => {
expect(() => JSON5.stringify({ a: 1, b: 2 }, ["a"])).toThrow(
"JSON5.stringify does not support the replacer argument",
);
});
test("space parameter with number", () => {
expect(JSON5.stringify({ a: 1 }, null, 2)).toEqual("{\n a: 1,\n}");
});
test("space parameter with string", () => {
expect(JSON5.stringify({ a: 1 }, null, "\t")).toEqual("{\n\ta: 1,\n}");
});
test("space parameter with multiple properties", () => {
expect(JSON5.stringify({ a: 1, b: 2 }, null, 2)).toEqual("{\n a: 1,\n b: 2,\n}");
});
test("space parameter with array", () => {
expect(JSON5.stringify([1, 2, 3], null, 2)).toEqual("[\n 1,\n 2,\n 3,\n]");
});
test("escapes U+2028 and U+2029 line separators", () => {
expect(JSON5.stringify("hello\u2028world")).toEqual("'hello\\u2028world'");
expect(JSON5.stringify("hello\u2029world")).toEqual("'hello\\u2029world'");
});
test("space parameter with Infinity/NaN/large numbers", () => {
expect(JSON5.stringify({ a: 1 }, null, Infinity)).toEqual(JSON5.stringify({ a: 1 }, null, 10));
expect(JSON5.stringify({ a: 1 }, null, -Infinity)).toEqual(JSON5.stringify({ a: 1 }));
expect(JSON5.stringify({ a: 1 }, null, NaN)).toEqual(JSON5.stringify({ a: 1 }));
expect(JSON5.stringify({ a: 1 }, null, 100)).toEqual(JSON5.stringify({ a: 1 }, null, 10));
expect(JSON5.stringify({ a: 1 }, null, 2147483648)).toEqual(JSON5.stringify({ a: 1 }, null, 10));
expect(JSON5.stringify({ a: 1 }, null, 3e9)).toEqual(JSON5.stringify({ a: 1 }, null, 10));
});
test("space parameter with boxed Number", () => {
expect(JSON5.stringify({ a: 1 }, null, new Number(4) as any)).toEqual(JSON5.stringify({ a: 1 }, null, 4));
expect(JSON5.stringify({ a: 1 }, null, new Number(0) as any)).toEqual(JSON5.stringify({ a: 1 }, null, 0));
expect(JSON5.stringify({ a: 1 }, null, new Number(-1) as any)).toEqual(JSON5.stringify({ a: 1 }, null, -1));
expect(JSON5.stringify({ a: 1 }, null, new Number(Infinity) as any)).toEqual(JSON5.stringify({ a: 1 }, null, 10));
expect(JSON5.stringify({ a: 1 }, null, new Number(NaN) as any)).toEqual(JSON5.stringify({ a: 1 }, null, 0));
});
test("space parameter with boxed String", () => {
expect(JSON5.stringify({ a: 1 }, null, new String("\t") as any)).toEqual(JSON5.stringify({ a: 1 }, null, "\t"));
expect(JSON5.stringify({ a: 1 }, null, new String("") as any)).toEqual(JSON5.stringify({ a: 1 }, null, ""));
});
test("space parameter with all-undefined properties produces empty object", () => {
expect(JSON5.stringify({ a: undefined, b: undefined }, null, 2)).toEqual("{}");
expect(JSON5.stringify({ a: () => {}, b: () => {} }, null, 2)).toEqual("{}");
});
test("undefined returns undefined", () => {
expect(JSON5.stringify(undefined)).toBeUndefined();
});
test("functions return undefined", () => {
expect(JSON5.stringify(() => {})).toBeUndefined();
});
test("circular reference throws", () => {
const obj: any = {};
obj.self = obj;
expect(() => JSON5.stringify(obj)).toThrow();
});
// Verified against json5@2.2.3 reference implementation
test("matches json5 npm output for all types", () => {
expect(JSON5.stringify(null)).toEqual("null");
expect(JSON5.stringify(true)).toEqual("true");
expect(JSON5.stringify(false)).toEqual("false");
expect(JSON5.stringify(42)).toEqual("42");
expect(JSON5.stringify(3.14)).toEqual("3.14");
expect(JSON5.stringify(-1)).toEqual("-1");
expect(JSON5.stringify(0)).toEqual("0");
expect(JSON5.stringify(Infinity)).toEqual("Infinity");
expect(JSON5.stringify(-Infinity)).toEqual("-Infinity");
expect(JSON5.stringify(NaN)).toEqual("NaN");
expect(JSON5.stringify("hello")).toEqual("'hello'");
expect(JSON5.stringify('he said "hi"')).toEqual("'he said \"hi\"'");
expect(JSON5.stringify("line\nnew")).toEqual("'line\\nnew'");
expect(JSON5.stringify("tab\there")).toEqual("'tab\\there'");
expect(JSON5.stringify("back\\slash")).toEqual("'back\\\\slash'");
expect(JSON5.stringify({ a: 1, b: "two" })).toEqual("{a:1,b:'two'}");
expect(JSON5.stringify({ "foo bar": 1 })).toEqual("{'foo bar':1}");
expect(JSON5.stringify({ "0key": 1 })).toEqual("{'0key':1}");
expect(JSON5.stringify({ "key-name": 1 })).toEqual("{'key-name':1}");
expect(JSON5.stringify({ "": 1 })).toEqual("{'':1}");
expect(JSON5.stringify([1, "two", true])).toEqual("[1,'two',true]");
expect(JSON5.stringify({ a: [1, { b: 2 }] })).toEqual("{a:[1,{b:2}]}");
expect(JSON5.stringify({ x: Infinity, y: NaN })).toEqual("{x:Infinity,y:NaN}");
expect(JSON5.stringify([Infinity, -Infinity, NaN])).toEqual("[Infinity,-Infinity,NaN]");
expect(JSON5.stringify(undefined)).toBeUndefined();
expect(JSON5.stringify(() => {})).toBeUndefined();
});
test("matches json5 npm pretty-print output", () => {
expect(JSON5.stringify({ a: 1 }, null, 2)).toEqual("{\n a: 1,\n}");
expect(JSON5.stringify({ a: 1 }, null, "\t")).toEqual("{\n\ta: 1,\n}");
expect(JSON5.stringify({ a: 1, b: 2 }, null, 2)).toEqual("{\n a: 1,\n b: 2,\n}");
expect(JSON5.stringify([1, 2, 3], null, 2)).toEqual("[\n 1,\n 2,\n 3,\n]");
});
});
describe("comments in all structural positions", () => {
test("comment between object key and colon", () => {
expect(JSON5.parse("{a /* c */ : 1}")).toEqual({ a: 1 });
expect(JSON5.parse("{a // c\n: 1}")).toEqual({ a: 1 });
});
test("comment between colon and value", () => {
expect(JSON5.parse("{a: /* c */ 1}")).toEqual({ a: 1 });
expect(JSON5.parse("{a: // c\n1}")).toEqual({ a: 1 });
});
test("comment between comma and next key", () => {
expect(JSON5.parse("{a: 1, /* c */ b: 2}")).toEqual({ a: 1, b: 2 });
expect(JSON5.parse("{a: 1, // c\nb: 2}")).toEqual({ a: 1, b: 2 });
});
test("comment after opening brace", () => {
expect(JSON5.parse("{ /* c */ a: 1}")).toEqual({ a: 1 });
expect(JSON5.parse("{ // c\na: 1}")).toEqual({ a: 1 });
});
test("comment before closing brace", () => {
expect(JSON5.parse("{a: 1 /* c */ }")).toEqual({ a: 1 });
expect(JSON5.parse("{a: 1 // c\n}")).toEqual({ a: 1 });
});
test("comment after trailing comma in object", () => {
expect(JSON5.parse("{a: 1, /* c */ }")).toEqual({ a: 1 });
expect(JSON5.parse("{a: 1, // c\n}")).toEqual({ a: 1 });
});
test("comment between array elements", () => {
expect(JSON5.parse("[1, /* c */ 2]")).toEqual([1, 2]);
expect(JSON5.parse("[1, // c\n2]")).toEqual([1, 2]);
});
test("comment after opening bracket", () => {
expect(JSON5.parse("[ /* c */ 1]")).toEqual([1]);
expect(JSON5.parse("[ // c\n1]")).toEqual([1]);
});
test("comment before closing bracket", () => {
expect(JSON5.parse("[1 /* c */ ]")).toEqual([1]);
expect(JSON5.parse("[1 // c\n]")).toEqual([1]);
});
test("comment after trailing comma in array", () => {
expect(JSON5.parse("[1, /* c */ ]")).toEqual([1]);
expect(JSON5.parse("[1, // c\n]")).toEqual([1]);
});
test("no whitespace/comments allowed between sign and value (per spec)", () => {
expect(() => JSON5.parse("+ /* c */ 1")).toThrow();
expect(() => JSON5.parse("- /* c */ 1")).toThrow();
expect(() => JSON5.parse("+ // c\n1")).toThrow();
expect(() => JSON5.parse("+ /* c */ Infinity")).toThrow();
expect(() => JSON5.parse("- /* c */ Infinity")).toThrow();
expect(() => JSON5.parse("+ /* c */ NaN")).toThrow();
expect(() => JSON5.parse("- /* c */ NaN")).toThrow();
});
test("block comment with asterisks inside", () => {
expect(JSON5.parse("/*** comment ***/ 42")).toEqual(42);
});
test("block comment with slashes inside", () => {
expect(JSON5.parse("/* // not line comment */ 42")).toEqual(42);
});
test("single-line comment terminated by U+2028", () => {
expect(JSON5.parse("// comment\u202842")).toEqual(42);
});
test("single-line comment terminated by U+2029", () => {
expect(JSON5.parse("// comment\u202942")).toEqual(42);
});
});
describe("whitespace in all structural positions", () => {
test("no whitespace allowed between sign and value (per spec)", () => {
expect(() => JSON5.parse("+ 1")).toThrow();
expect(() => JSON5.parse("- 1")).toThrow();
expect(() => JSON5.parse("+ \t 1")).toThrow();
expect(() => JSON5.parse("- \n 1")).toThrow();
expect(() => JSON5.parse("+\u00A01")).toThrow();
expect(() => JSON5.parse("-\u00A01")).toThrow();
expect(() => JSON5.parse("+\u20001")).toThrow();
});
test("all unicode whitespace types as separators", () => {
// U+1680 OGHAM SPACE MARK
expect(JSON5.parse("\u168042")).toEqual(42);
// U+2000 EN QUAD
expect(JSON5.parse("\u200042")).toEqual(42);
// U+2001 EM QUAD
expect(JSON5.parse("\u200142")).toEqual(42);
// U+2002 EN SPACE
expect(JSON5.parse("\u200242")).toEqual(42);
// U+2003 EM SPACE
expect(JSON5.parse("\u200342")).toEqual(42);
// U+2004 THREE-PER-EM SPACE
expect(JSON5.parse("\u200442")).toEqual(42);
// U+2005 FOUR-PER-EM SPACE
expect(JSON5.parse("\u200542")).toEqual(42);
// U+2006 SIX-PER-EM SPACE
expect(JSON5.parse("\u200642")).toEqual(42);
// U+2007 FIGURE SPACE
expect(JSON5.parse("\u200742")).toEqual(42);
// U+2008 PUNCTUATION SPACE
expect(JSON5.parse("\u200842")).toEqual(42);
// U+2009 THIN SPACE
expect(JSON5.parse("\u200942")).toEqual(42);
// U+200A HAIR SPACE
expect(JSON5.parse("\u200A42")).toEqual(42);
// U+202F NARROW NO-BREAK SPACE
expect(JSON5.parse("\u202F42")).toEqual(42);
// U+205F MEDIUM MATHEMATICAL SPACE
expect(JSON5.parse("\u205F42")).toEqual(42);
// U+3000 IDEOGRAPHIC SPACE
expect(JSON5.parse("\u300042")).toEqual(42);
});
test("mixed whitespace and comments", () => {
expect(JSON5.parse(" \t\n /* comment */ \r\n // line comment\n 42 \t ")).toEqual(42);
});
});
describe("unicode identifier keys", () => {
test("unicode letter keys", () => {
expect(JSON5.parse("{café: 1}")).toEqual({ café: 1 });
expect(JSON5.parse("{naïve: 2}")).toEqual({ naïve: 2 });
expect(JSON5.parse("{über: 3}")).toEqual({ über: 3 });
});
test("CJK identifier keys", () => {
expect(JSON5.parse("{日本語: 1}")).toEqual({ 日本語: 1 });
expect(JSON5.parse("{中文: 2}")).toEqual({ 中文: 2 });
});
test("unicode escape in identifier key", () => {
expect(JSON5.parse("{\\u0061: 1}")).toEqual({ a: 1 });
expect(JSON5.parse("{\\u0041bc: 1}")).toEqual({ Abc: 1 });
});
test("unicode escape for non-ASCII start char", () => {
// \u00E9 is é
expect(JSON5.parse("{\\u00E9: 1}")).toEqual({ é: 1 });
});
test("mixed unicode escape and literal", () => {
expect(JSON5.parse("{\\u0061bc: 1}")).toEqual({ abc: 1 });
});
});
describe("reserved words as keys", () => {
test("ES5 future reserved words as unquoted keys", () => {
expect(JSON5.parse("{class: 1}")).toEqual({ class: 1 });
expect(JSON5.parse("{enum: 2}")).toEqual({ enum: 2 });
expect(JSON5.parse("{extends: 3}")).toEqual({ extends: 3 });
expect(JSON5.parse("{super: 4}")).toEqual({ super: 4 });
expect(JSON5.parse("{const: 5}")).toEqual({ const: 5 });
expect(JSON5.parse("{export: 6}")).toEqual({ export: 6 });
expect(JSON5.parse("{import: 7}")).toEqual({ import: 7 });
});
test("strict mode reserved words as unquoted keys", () => {
expect(JSON5.parse("{implements: 1}")).toEqual({ implements: 1 });
expect(JSON5.parse("{interface: 2}")).toEqual({ interface: 2 });
expect(JSON5.parse("{let: 3}")).toEqual({ let: 3 });
expect(JSON5.parse("{package: 4}")).toEqual({ package: 4 });
expect(JSON5.parse("{private: 5}")).toEqual({ private: 5 });
expect(JSON5.parse("{protected: 6}")).toEqual({ protected: 6 });
expect(JSON5.parse("{public: 7}")).toEqual({ public: 7 });
expect(JSON5.parse("{static: 8}")).toEqual({ static: 8 });
expect(JSON5.parse("{yield: 9}")).toEqual({ yield: 9 });
});
test("NaN and Infinity as keys", () => {
expect(JSON5.parse("{NaN: 1}")).toEqual({ NaN: 1 });
expect(JSON5.parse("{Infinity: 2}")).toEqual({ Infinity: 2 });
});
test("signed NaN/Infinity as keys should error", () => {
expect(() => JSON5.parse("{-Infinity: 1}")).toThrow();
expect(() => JSON5.parse("{+Infinity: 1}")).toThrow();
expect(() => JSON5.parse("{-NaN: 1}")).toThrow();
expect(() => JSON5.parse("{+NaN: 1}")).toThrow();
});
test("numeric literals as keys should error", () => {
expect(() => JSON5.parse("{123: 1}")).toThrow();
expect(() => JSON5.parse("{0xFF: 1}")).toThrow();
expect(() => JSON5.parse("{3.14: 1}")).toThrow();
expect(() => JSON5.parse("{-1: 1}")).toThrow();
expect(() => JSON5.parse("{+1: 1}")).toThrow();
});
test("NaN and Infinity as values still work", () => {
expect(Number.isNaN(JSON5.parse("{a: NaN}").a)).toBe(true);
expect(JSON5.parse("{a: Infinity}").a).toBe(Infinity);
expect(JSON5.parse("{a: -Infinity}").a).toBe(-Infinity);
expect(Number.isNaN(JSON5.parse("{a: +NaN}").a)).toBe(true);
expect(Number.isNaN(JSON5.parse("{a: -NaN}").a)).toBe(true);
expect(JSON5.parse("{a: +Infinity}").a).toBe(Infinity);
});
test("keyword-like identifiers as values should error", () => {
expect(() => JSON5.parse("{a: undefined}")).toThrow("Unexpected token");
expect(() => JSON5.parse("{a: class}")).toThrow("Unexpected token");
expect(() => JSON5.parse("{a: var}")).toThrow("Unexpected token");
});
});
describe("number edge cases", () => {
test("double sign throws", () => {
expect(() => JSON5.parse("++1")).toThrow("Unexpected character");
expect(() => JSON5.parse("--1")).toThrow("Unexpected character");
expect(() => JSON5.parse("+-1")).toThrow("Unexpected character");
});
test("negative hex zero", () => {
expect(Object.is(JSON5.parse("-0x0"), -0)).toBe(true);
});
test("positive hex", () => {
expect(JSON5.parse("+0xFF")).toEqual(255);
});
test("hex zero", () => {
expect(JSON5.parse("0x0")).toEqual(0);
expect(JSON5.parse("0x00")).toEqual(0);
});
test("exponent with explicit positive sign", () => {
expect(JSON5.parse("1e+2")).toEqual(100);
expect(JSON5.parse("1E+2")).toEqual(100);
});
test("exponent with negative sign", () => {
expect(JSON5.parse("1e-2")).toEqual(0.01);
expect(JSON5.parse("1E-2")).toEqual(0.01);
});
test("zero with exponent", () => {
expect(JSON5.parse("0e0")).toEqual(0);
expect(JSON5.parse("0e1")).toEqual(0);
});
test("very large number", () => {
expect(JSON5.parse("1e308")).toEqual(1e308);
});
test("number overflows to Infinity", () => {
expect(JSON5.parse("1e309")).toEqual(Infinity);
});
test("very small number", () => {
expect(JSON5.parse("5e-324")).toEqual(5e-324);
});
test("positive zero variations", () => {
expect(Object.is(JSON5.parse("0"), 0)).toBe(true);
expect(Object.is(JSON5.parse("0.0"), 0)).toBe(true);
expect(Object.is(JSON5.parse("+0"), 0)).toBe(true);
});
test("fractional only number", () => {
expect(JSON5.parse(".123")).toEqual(0.123);
expect(JSON5.parse("+.5")).toEqual(0.5);
expect(JSON5.parse("-.5")).toEqual(-0.5);
});
test("trailing decimal", () => {
expect(JSON5.parse("5.")).toEqual(5);
expect(JSON5.parse("+5.")).toEqual(5);
expect(JSON5.parse("-5.")).toEqual(-5);
});
});
describe("surrogate pair edge cases", () => {
test("valid surrogate pair", () => {
// U+1F600 = D83D DE00 (😀)
// U+1F601 = D83D DE01 (😁)
expect(JSON5.parse('"\\uD83D\\uDE00\\uD83D\\uDE01"')).toEqual("😀😁");
});
test("surrogate pair for musical symbol", () => {
// U+1D11E MUSICAL SYMBOL G CLEF = D834 DD1E
expect(JSON5.parse('"\\uD834\\uDD1E"')).toEqual("𝄞");
});
});
describe("string edge cases", () => {
test("empty string", () => {
expect(JSON5.parse('""')).toEqual("");
expect(JSON5.parse("''")).toEqual("");
});
test("string with only whitespace", () => {
expect(JSON5.parse('" "')).toEqual(" ");
expect(JSON5.parse('"\\t"')).toEqual("\t");
});
test("single-quoted string with double quotes", () => {
expect(JSON5.parse("'hello \"world\"'")).toEqual('hello "world"');
});
test("double-quoted string with single quotes", () => {
expect(JSON5.parse("\"hello 'world'\"")).toEqual("hello 'world'");
});
test("string with all escape types", () => {
const input = '"\\b\\f\\n\\r\\t\\v\\0\\\\\\/\\\'\\""';
const expected = "\b\f\n\r\t\v\0\\/'\"";
expect(JSON5.parse(input)).toEqual(expected);
});
test("line continuation with U+2028", () => {
const input = '"line1\\\u2028line2"';
expect(JSON5.parse(input)).toEqual("line1line2");
});
test("line continuation with U+2029", () => {
const input = '"line1\\\u2029line2"';
expect(JSON5.parse(input)).toEqual("line1line2");
});
test("multiple line continuations", () => {
expect(JSON5.parse('"a\\\nb\\\nc"')).toEqual("abc");
});
});
describe("deeply nested structures", () => {
test("deeply nested arrays", () => {
const depth = 100;
const input = "[".repeat(depth) + "1" + "]".repeat(depth);
let expected: any = 1;
for (let i = 0; i < depth; i++) expected = [expected];
expect(JSON5.parse(input)).toEqual(expected);
});
test("deeply nested objects", () => {
const depth = 100;
let input = "";
for (let i = 0; i < depth; i++) input += `{a${i}: `;
input += "1";
for (let i = 0; i < depth; i++) input += "}";
const result = JSON5.parse(input);
// Navigate to innermost value
let current: any = result;
for (let i = 0; i < depth; i++) current = current[`a${i}`];
expect(current).toEqual(1);
});
test("mixed nesting", () => {
expect(JSON5.parse("{a: [{b: [{c: 1}]}]}")).toEqual({ a: [{ b: [{ c: 1 }] }] });
});
});
describe("empty inputs", () => {
test("empty string throws", () => {
expect(() => JSON5.parse("")).toThrow("Unexpected end of input");
});
test("only whitespace throws", () => {
expect(() => JSON5.parse(" ")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("\t\n\r")).toThrow("Unexpected end of input");
});
test("only comments throws", () => {
expect(() => JSON5.parse("// comment")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("/* comment */")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("/* a */ // b")).toThrow("Unexpected end of input");
});
test("only unicode whitespace throws", () => {
expect(() => JSON5.parse("\u00A0")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("\uFEFF")).toThrow("Unexpected end of input");
expect(() => JSON5.parse("\u2000\u2001\u2002")).toThrow("Unexpected end of input");
});
});
describe("garbage input", () => {
test("single punctuation characters", () => {
expect(() => JSON5.parse("@")).toThrow("Unexpected character");
expect(() => JSON5.parse("#")).toThrow("Unexpected character");
expect(() => JSON5.parse("!")).toThrow("Unexpected character");
expect(() => JSON5.parse("~")).toThrow("Unexpected character");
expect(() => JSON5.parse("`")).toThrow("Unexpected character");
expect(() => JSON5.parse("^")).toThrow("Unexpected character");
expect(() => JSON5.parse("&")).toThrow("Unexpected character");
expect(() => JSON5.parse("|")).toThrow("Unexpected character");
expect(() => JSON5.parse("=")).toThrow("Unexpected character");
expect(() => JSON5.parse("<")).toThrow("Unexpected character");
expect(() => JSON5.parse(">")).toThrow("Unexpected character");
expect(() => JSON5.parse("?")).toThrow("Unexpected character");
expect(() => JSON5.parse(";")).toThrow("Unexpected character");
});
test("bare slash is not a comment", () => {
expect(() => JSON5.parse("/")).toThrow("Unexpected character");
expect(() => JSON5.parse("/ /")).toThrow("Unexpected character");
});
test("random words throw", () => {
expect(() => JSON5.parse("undefined")).toThrow("Unexpected token");
expect(() => JSON5.parse("foo")).toThrow("Unexpected token");
expect(() => JSON5.parse("var")).toThrow("Unexpected token");
expect(() => JSON5.parse("function")).toThrow("Unexpected token");
expect(() => JSON5.parse("return")).toThrow("Unexpected token");
});
test("javascript expressions throw", () => {
expect(() => JSON5.parse("1 + 2")).toThrow();
expect(() => JSON5.parse("a = 1")).toThrow();
expect(() => JSON5.parse("(1)")).toThrow();
expect(() => JSON5.parse("{}{}")).toThrow();
expect(() => JSON5.parse("[][]")).toThrow();
});
test("incomplete structures", () => {
expect(() => JSON5.parse("{")).toThrow();
expect(() => JSON5.parse("[")).toThrow();
expect(() => JSON5.parse("{a:")).toThrow();
expect(() => JSON5.parse("{a: 1,")).toThrow();
expect(() => JSON5.parse("[1,")).toThrow();
expect(() => JSON5.parse("'unterminated")).toThrow();
expect(() => JSON5.parse('"unterminated')).toThrow();
});
test("binary data throws", () => {
expect(() => JSON5.parse("\x01\x02\x03")).toThrow();
expect(() => JSON5.parse("\x00")).toThrow();
expect(() => JSON5.parse("\x7F")).toThrow();
});
});
describe("input types", () => {
test("accepts Buffer input", () => {
const input: any = Buffer.from('{"a": 1}');
const parsed = JSON5.parse(input);
const expected: any = { a: 1 };
expect(parsed).toEqual(expected);
});
test("accepts ArrayBuffer input", () => {
const input: any = new TextEncoder().encode('{"a": 1}').buffer;
const parsed = JSON5.parse(input);
const expected: any = { a: 1 };
expect(parsed).toEqual(expected);
});
test("accepts Uint8Array input", () => {
const input: any = new TextEncoder().encode("[1, 2, 3]");
const parsed = JSON5.parse(input);
const expected: any = [1, 2, 3];
expect(parsed).toEqual(expected);
});
test("throws on no arguments", () => {
expect(() => (JSON5.parse as any)()).toThrow();
});
test("throws on undefined argument", () => {
expect(() => JSON5.parse(undefined as any)).toThrow();
});
test("throws on null argument", () => {
expect(() => JSON5.parse(null as any)).toThrow();
});
});
// Helper for comparing values that may contain NaN
function deepEqual(a: any, b: any): boolean {
if (typeof a === "number" && typeof b === "number") {
if (Number.isNaN(a) && Number.isNaN(b)) return true;
return Object.is(a, b);
}
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false;
return a.every((v: any, i: number) => deepEqual(v, b[i]));
}
if (typeof a === "object") {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every(k => deepEqual(a[k], b[k]));
}
return false;
}
describe("round-trip: parse → stringify → parse", () => {
// Parse JSON5 input, stringify the result, parse again — values must match
function psp(input: string) {
const first = JSON5.parse(input);
const stringified = JSON5.stringify(first);
const second = JSON5.parse(stringified);
expect(deepEqual(first, second)).toBe(true);
}
describe("primitives", () => {
test("null", () => psp("null"));
test("true", () => psp("true"));
test("false", () => psp("false"));
});
describe("numbers", () => {
test("zero", () => psp("0"));
test("positive integer", () => psp("42"));
test("negative integer", () => psp("-42"));
test("float", () => psp("3.14"));
test("negative float", () => psp("-3.14"));
test("leading decimal point", () => psp(".5"));
test("negative leading decimal", () => psp("-.5"));
test("exponent notation", () => psp("1e10"));
test("negative exponent", () => psp("1e-5"));
test("positive exponent", () => psp("1E+3"));
test("hex integer", () => psp("0xFF"));
test("negative hex", () => psp("-0xFF"));
test("Infinity", () => psp("Infinity"));
test("-Infinity", () => psp("-Infinity"));
test("+Infinity", () => psp("+Infinity"));
test("NaN", () => psp("NaN"));
test("explicit positive", () => psp("+42"));
test("explicit positive float", () => psp("+3.14"));
});
describe("strings", () => {
test("empty single-quoted", () => psp("''"));
test("empty double-quoted", () => psp('""'));
test("simple single-quoted", () => psp("'hello'"));
test("simple double-quoted", () => psp('"hello"'));
test("string with spaces", () => psp("'hello world'"));
test("string with escape sequences", () => psp("'\\n\\t\\r\\b\\f'"));
test("string with unicode escape", () => psp("'\\u0041'"));
test("string with backslash", () => psp("'\\\\'"));
test("string with single quote escape", () => psp("'it\\'s'"));
test("string with null char escape", () => psp("'\\0'"));
test("unicode characters", () => psp("'日本語'"));
test("emoji", () => psp("'😀'"));
});
describe("arrays", () => {
test("empty array", () => psp("[]"));
test("single element", () => psp("[1]"));
test("multiple elements", () => psp("[1, 2, 3]"));
test("mixed types", () => psp("[1, 'two', true, null, Infinity]"));
test("nested arrays", () => psp("[[1, 2], [3, 4]]"));
test("array with trailing comma", () => psp("[1, 2, 3,]"));
test("sparse-looking array with nulls", () => psp("[null, null, null]"));
});
describe("objects", () => {
test("empty object", () => psp("{}"));
test("single property", () => psp("{a: 1}"));
test("multiple properties", () => psp("{a: 1, b: 2, c: 3}"));
test("quoted keys", () => psp("{'a': 1, \"b\": 2}"));
test("nested objects", () => psp("{a: {b: {c: 1}}}"));
test("mixed values", () => psp("{a: 1, b: 'two', c: true, d: null, e: [1, 2]}"));
test("trailing comma", () => psp("{a: 1, b: 2,}"));
test("NaN as key", () => psp("{NaN: 1}"));
test("Infinity as key", () => psp("{Infinity: 1}"));
test("null as key", () => psp("{null: 1}"));
test("true as key", () => psp("{true: 1}"));
test("false as key", () => psp("{false: 1}"));
test("key with $ prefix", () => psp("{$key: 1}"));
test("key with _ prefix", () => psp("{_key: 1}"));
test("key with unicode letters", () => psp("{café: 1}"));
});
describe("complex structures", () => {
test("array of objects", () => psp("[{a: 1}, {b: 2}, {c: 3}]"));
test("object with array values", () => psp("{a: [1, 2], b: [3, 4]}"));
test("deeply nested", () => psp("{a: {b: [{c: {d: [1, 2, 3]}}]}}"));
test("config-like structure", () =>
psp(`{
name: 'my-app',
version: '1.0.0',
debug: true,
port: 3000,
tags: ['web', 'api'],
db: {
host: 'localhost',
port: 5432,
},
}`));
});
});
describe("round-trip: stringify → parse → stringify", () => {
// Stringify a JS value, parse the result, stringify again — strings must match
function sps(value: any) {
const first = JSON5.stringify(value);
const parsed = JSON5.parse(first);
const second = JSON5.stringify(parsed);
expect(second).toBe(first);
}
// With a space argument for pretty printing
function spsPretty(value: any, space: number | string = 2) {
const first = JSON5.stringify(value, null, space);
const parsed = JSON5.parse(first);
const second = JSON5.stringify(parsed, null, space);
expect(second).toBe(first);
}
describe("primitives", () => {
test("null", () => sps(null));
test("true", () => sps(true));
test("false", () => sps(false));
});
describe("numbers", () => {
test("zero", () => sps(0));
test("positive integer", () => sps(42));
test("negative integer", () => sps(-42));
test("float", () => sps(3.14));
test("negative float", () => sps(-3.14));
test("very small float", () => sps(0.000001));
test("very large number", () => sps(1e20));
test("Infinity", () => sps(Infinity));
test("-Infinity", () => sps(-Infinity));
test("NaN", () => sps(NaN));
test("MAX_SAFE_INTEGER", () => sps(Number.MAX_SAFE_INTEGER));
test("MIN_SAFE_INTEGER", () => sps(Number.MIN_SAFE_INTEGER));
});
describe("strings", () => {
test("empty string", () => sps(""));
test("simple string", () => sps("hello"));
test("string with spaces", () => sps("hello world"));
test("string with newline", () => sps("line1\nline2"));
test("string with tab", () => sps("col1\tcol2"));
test("string with backslash", () => sps("path\\to\\file"));
test("string with single quotes", () => sps("it's"));
test("string with null char", () => sps("null\0char"));
test("unicode string", () => sps("日本語"));
test("emoji string", () => sps("😀🎉"));
test("string with control chars", () => sps("\x01\x02\x03"));
});
describe("arrays", () => {
test("empty array", () => sps([]));
test("single element", () => sps([1]));
test("multiple numbers", () => sps([1, 2, 3]));
test("mixed types", () => sps([1, "two", true, null]));
test("nested arrays", () =>
sps([
[1, 2],
[3, 4],
]));
test("array with special numbers", () => sps([Infinity, -Infinity, NaN]));
test("array with objects", () => sps([{ a: 1 }, { b: 2 }]));
});
describe("objects", () => {
test("empty object", () => sps({}));
test("single property", () => sps({ a: 1 }));
test("multiple properties", () => sps({ a: 1, b: 2, c: 3 }));
test("nested object", () => sps({ a: { b: { c: 1 } } }));
test("mixed value types", () => sps({ num: 42, str: "hello", bool: true, nil: null }));
test("object with array value", () => sps({ items: [1, 2, 3] }));
test("object with special number values", () => sps({ inf: Infinity, ninf: -Infinity, nan: NaN }));
test("key needing quotes (has space)", () => sps({ "key with spaces": 1 }));
test("key needing quotes (starts with number)", () => sps({ "0abc": 1 }));
test("key needing quotes (has hyphen)", () => sps({ "my-key": 1 }));
test("key with $", () => sps({ $key: 1 }));
test("key with _", () => sps({ _key: 1 }));
});
describe("complex structures", () => {
test("package.json-like", () =>
sps({
name: "my-package",
version: "1.0.0",
private: true,
dependencies: { react: "^18.0.0", next: "^13.0.0" },
scripts: { build: "next build", dev: "next dev" },
}));
test("config with arrays and nesting", () =>
sps({
server: { host: "localhost", port: 8080 },
features: ["auth", "logging"],
limits: { maxRequests: Infinity, timeout: 30000 },
}));
});
describe("pretty-printed", () => {
test("simple object with 2-space indent", () => spsPretty({ a: 1, b: 2 }));
test("nested object with 4-space indent", () => spsPretty({ a: { b: 1 } }, 4));
test("array with tab indent", () => spsPretty([1, 2, 3], "\t"));
test("complex structure", () =>
spsPretty({
name: "test",
items: [1, "two", true],
nested: { a: { b: [null, Infinity] } },
}));
test("empty containers pretty-printed", () => spsPretty({ arr: [], obj: {} }));
});
describe("undefined/symbol/function values", () => {
test("undefined in object is omitted", () => {
const obj = { a: 1, b: undefined, c: 3 };
const s1 = JSON5.stringify(obj);
const parsed = JSON5.parse(s1);
const s2 = JSON5.stringify(parsed);
expect(s2).toBe(s1);
expect(parsed).toEqual({ a: 1, c: 3 });
});
test("undefined in array becomes null", () => {
const arr = [1, undefined, 3];
const s1 = JSON5.stringify(arr);
const parsed = JSON5.parse(s1);
const s2 = JSON5.stringify(parsed);
expect(s2).toBe(s1);
expect(parsed).toEqual([1, null, 3]);
});
});
});