diff --git a/bench/json5/bun.lock b/bench/json5/bun.lock
new file mode 100644
index 0000000000..422be87efa
--- /dev/null
+++ b/bench/json5/bun.lock
@@ -0,0 +1,15 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "json5-benchmark",
+ "dependencies": {
+ "json5": "^2.2.3",
+ },
+ },
+ },
+ "packages": {
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+ }
+}
diff --git a/bench/json5/json5.mjs b/bench/json5/json5.mjs
new file mode 100644
index 0000000000..d5998d7edd
--- /dev/null
+++ b/bench/json5/json5.mjs
@@ -0,0 +1,88 @@
+import JSON5 from "json5";
+import { bench, group, run } from "../runner.mjs";
+
+const isBun = typeof Bun !== "undefined" && Bun.JSON5;
+
+function sizeLabel(n) {
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`;
+ if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`;
+ return `${n}B`;
+}
+
+// -- parse inputs --
+
+const smallJson5 = `{
+ // User profile
+ name: "John Doe",
+ age: 30,
+ email: 'john@example.com',
+ active: true,
+}`;
+
+function generateLargeJson5(count) {
+ const lines = ["{\n // Auto-generated dataset\n items: [\n"];
+ for (let i = 0; i < count; i++) {
+ lines.push(` {
+ id: ${i},
+ name: 'item_${i}',
+ value: ${(Math.random() * 1000).toFixed(2)},
+ hex: 0x${i.toString(16).toUpperCase()},
+ active: ${i % 2 === 0},
+ tags: ['tag_${i % 10}', 'category_${i % 5}',],
+ // entry ${i}
+ },\n`);
+ }
+ lines.push(" ],\n total: " + count + ",\n status: 'complete',\n}\n");
+ return lines.join("");
+}
+
+const largeJson5 = generateLargeJson5(6500);
+
+// -- stringify inputs --
+
+const smallObject = {
+ name: "John Doe",
+ age: 30,
+ email: "john@example.com",
+ active: true,
+};
+
+const largeObject = {
+ items: Array.from({ length: 10000 }, (_, i) => ({
+ id: i,
+ name: `item_${i}`,
+ value: +(Math.random() * 1000).toFixed(2),
+ active: i % 2 === 0,
+ tags: [`tag_${i % 10}`, `category_${i % 5}`],
+ })),
+ total: 10000,
+ status: "complete",
+};
+
+const stringify = isBun ? Bun.JSON5.stringify : JSON5.stringify;
+
+// -- parse benchmarks --
+
+group(`parse small (${sizeLabel(smallJson5.length)})`, () => {
+ if (isBun) bench("Bun.JSON5.parse", () => Bun.JSON5.parse(smallJson5));
+ bench("json5.parse", () => JSON5.parse(smallJson5));
+});
+
+group(`parse large (${sizeLabel(largeJson5.length)})`, () => {
+ if (isBun) bench("Bun.JSON5.parse", () => Bun.JSON5.parse(largeJson5));
+ bench("json5.parse", () => JSON5.parse(largeJson5));
+});
+
+// -- stringify benchmarks --
+
+group(`stringify small (${sizeLabel(stringify(smallObject).length)})`, () => {
+ if (isBun) bench("Bun.JSON5.stringify", () => Bun.JSON5.stringify(smallObject));
+ bench("json5.stringify", () => JSON5.stringify(smallObject));
+});
+
+group(`stringify large (${sizeLabel(stringify(largeObject).length)})`, () => {
+ if (isBun) bench("Bun.JSON5.stringify", () => Bun.JSON5.stringify(largeObject));
+ bench("json5.stringify", () => JSON5.stringify(largeObject));
+});
+
+await run();
diff --git a/bench/json5/package.json b/bench/json5/package.json
new file mode 100644
index 0000000000..0e51ad2c60
--- /dev/null
+++ b/bench/json5/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "json5-benchmark",
+ "version": "1.0.0",
+ "dependencies": {
+ "json5": "^2.2.3"
+ }
+}
diff --git a/docs/docs.json b/docs/docs.json
index 6b8e6983cb..7b8acdcbbc 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -150,6 +150,7 @@
"/runtime/secrets",
"/runtime/console",
"/runtime/yaml",
+ "/runtime/json5",
"/runtime/jsonl",
"/runtime/html-rewriter",
"/runtime/hashing",
@@ -498,6 +499,7 @@
"/guides/runtime/import-json",
"/guides/runtime/import-toml",
"/guides/runtime/import-yaml",
+ "/guides/runtime/import-json5",
"/guides/runtime/import-html",
"/guides/util/import-meta-dir",
"/guides/util/import-meta-file",
diff --git a/docs/guides/runtime/import-json5.mdx b/docs/guides/runtime/import-json5.mdx
new file mode 100644
index 0000000000..80f9405418
--- /dev/null
+++ b/docs/guides/runtime/import-json5.mdx
@@ -0,0 +1,74 @@
+---
+title: Import a JSON5 file
+sidebarTitle: Import JSON5
+mode: center
+---
+
+Bun natively supports `.json5` imports.
+
+```json5 config.json5 icon="file-code"
+{
+ // Comments are allowed
+ database: {
+ host: "localhost",
+ port: 5432,
+ name: "myapp",
+ },
+
+ server: {
+ port: 3000,
+ timeout: 30,
+ },
+
+ features: {
+ auth: true,
+ rateLimit: true,
+ },
+}
+```
+
+---
+
+Import the file like any other source file.
+
+```ts config.ts icon="/icons/typescript.svg"
+import config from "./config.json5";
+
+config.database.host; // => "localhost"
+config.server.port; // => 3000
+config.features.auth; // => true
+```
+
+---
+
+You can also use named imports to destructure top-level properties:
+
+```ts config.ts icon="/icons/typescript.svg"
+import { database, server, features } from "./config.json5";
+
+console.log(database.name); // => "myapp"
+console.log(server.timeout); // => 30
+console.log(features.rateLimit); // => true
+```
+
+---
+
+For parsing JSON5 strings at runtime, use `Bun.JSON5.parse()`:
+
+```ts config.ts icon="/icons/typescript.svg"
+const data = JSON5.parse(`{
+ name: 'John Doe',
+ age: 30,
+ hobbies: [
+ 'reading',
+ 'coding',
+ ],
+}`);
+
+console.log(data.name); // => "John Doe"
+console.log(data.hobbies); // => ["reading", "coding"]
+```
+
+---
+
+See [Docs > API > JSON5](/runtime/json5) for complete documentation on JSON5 support in Bun.
diff --git a/docs/runtime/file-types.mdx b/docs/runtime/file-types.mdx
index 89e23779c1..cd3db4d143 100644
--- a/docs/runtime/file-types.mdx
+++ b/docs/runtime/file-types.mdx
@@ -5,7 +5,7 @@ description: "File types and loaders supported by Bun's bundler and runtime"
The Bun bundler implements a set of default loaders out of the box. As a rule of thumb, the bundler and the runtime both support the same set of file types out of the box.
-`.js` `.cjs` `.mjs` `.mts` `.cts` `.ts` `.tsx` `.jsx` `.css` `.json` `.jsonc` `.toml` `.yaml` `.yml` `.txt` `.wasm` `.node` `.html` `.sh`
+`.js` `.cjs` `.mjs` `.mts` `.cts` `.ts` `.tsx` `.jsx` `.css` `.json` `.jsonc` `.json5` `.toml` `.yaml` `.yml` `.txt` `.wasm` `.node` `.html` `.sh`
Bun uses the file extension to determine which built-in _loader_ should be used to parse the file. Every loader has a name, such as `js`, `tsx`, or `json`. These names are used when building [plugins](/bundler/plugins) that extend Bun with custom loaders.
@@ -197,6 +197,53 @@ export default {
+### `json5`
+
+**JSON5 loader**. Default for `.json5`.
+
+JSON5 files can be directly imported. Bun will parse them with its fast native JSON5 parser. JSON5 is a superset of JSON that supports comments, trailing commas, unquoted keys, single-quoted strings, and more.
+
+```ts
+import config from "./config.json5";
+console.log(config);
+
+// via import attribute:
+import data from "./data.txt" with { type: "json5" };
+```
+
+During bundling, the parsed JSON5 is inlined into the bundle as a JavaScript object.
+
+```ts
+var config = {
+ name: "my-app",
+ version: "1.0.0",
+ // ...other fields
+};
+```
+
+If a `.json5` file is passed as an entrypoint, it will be converted to a `.js` module that `export default`s the parsed object.
+
+
+
+```json5 Input
+{
+ // Configuration
+ name: "John Doe",
+ age: 35,
+ email: "johndoe@example.com",
+}
+```
+
+```ts Output
+export default {
+ name: "John Doe",
+ age: 35,
+ email: "johndoe@example.com",
+};
+```
+
+
+
### `text`
**Text loader**. Default for `.txt`.
diff --git a/docs/runtime/json5.mdx b/docs/runtime/json5.mdx
new file mode 100644
index 0000000000..1dc9aeebd5
--- /dev/null
+++ b/docs/runtime/json5.mdx
@@ -0,0 +1,271 @@
+---
+title: JSON5
+description: Use Bun's built-in support for JSON5 files through both runtime APIs and bundler integration
+---
+
+In Bun, JSON5 is a first-class citizen alongside JSON, TOML, and YAML. You can:
+
+- Parse and stringify JSON5 with `Bun.JSON5.parse` and `Bun.JSON5.stringify`
+- `import` & `require` JSON5 files as modules at runtime (including hot reloading & watch mode support)
+- `import` & `require` JSON5 files in frontend apps via Bun's bundler
+
+---
+
+## Conformance
+
+Bun's JSON5 parser passes 100% of the [official JSON5 test suite](https://github.com/json5/json5-tests). The parser is written in Zig for optimal performance. You can view our [translated test suite](https://github.com/oven-sh/bun/blob/main/test/js/bun/json5/json5-test-suite.test.ts) to see every test case.
+
+---
+
+## Runtime API
+
+### `Bun.JSON5.parse()`
+
+Parse a JSON5 string into a JavaScript value.
+
+```ts
+import { JSON5 } from "bun";
+
+const data = JSON5.parse(`{
+ // JSON5 supports comments
+ name: 'my-app',
+ version: '1.0.0',
+ debug: true,
+
+ // trailing commas are allowed
+ tags: ['web', 'api',],
+}`);
+
+console.log(data);
+// {
+// name: "my-app",
+// version: "1.0.0",
+// debug: true,
+// tags: ["web", "api"]
+// }
+```
+
+#### Supported JSON5 Features
+
+JSON5 is a superset of JSON based on ECMAScript 5.1 syntax. It supports:
+
+- **Comments**: single-line (`//`) and multi-line (`/* */`)
+- **Trailing commas**: in objects and arrays
+- **Unquoted keys**: valid ECMAScript 5.1 identifiers can be used as keys
+- **Single-quoted strings**: in addition to double-quoted strings
+- **Multi-line strings**: using backslash line continuations
+- **Hex numbers**: `0xFF`
+- **Leading & trailing decimal points**: `.5` and `5.`
+- **Infinity and NaN**: positive and negative
+- **Explicit plus sign**: `+42`
+
+```ts
+const data = JSON5.parse(`{
+ // Unquoted keys
+ unquoted: 'keys work',
+
+ // Single and double quotes
+ single: 'single-quoted',
+ double: "double-quoted",
+
+ // Trailing commas
+ trailing: 'comma',
+
+ // Special numbers
+ hex: 0xDEADbeef,
+ half: .5,
+ to: Infinity,
+ nan: NaN,
+
+ // Multi-line strings
+ multiline: 'line 1 \
+line 2',
+}`);
+```
+
+#### Error Handling
+
+`Bun.JSON5.parse()` throws a `SyntaxError` if the input is invalid JSON5:
+
+```ts
+try {
+ JSON5.parse("{invalid}");
+} catch (error) {
+ console.error("Failed to parse JSON5:", error.message);
+}
+```
+
+### `Bun.JSON5.stringify()`
+
+Stringify a JavaScript value to a JSON5 string.
+
+```ts
+import { JSON5 } from "bun";
+
+const str = JSON5.stringify({ name: "my-app", version: "1.0.0" });
+console.log(str);
+// {name:'my-app',version:'1.0.0'}
+```
+
+#### Pretty Printing
+
+Pass a `space` argument to format the output with indentation:
+
+```ts
+const pretty = JSON5.stringify(
+ {
+ name: "my-app",
+ debug: true,
+ tags: ["web", "api"],
+ },
+ null,
+ 2,
+);
+
+console.log(pretty);
+// {
+// name: 'my-app',
+// debug: true,
+// tags: [
+// 'web',
+// 'api',
+// ],
+// }
+```
+
+The `space` argument can be a number (number of spaces) or a string (used as the indent character):
+
+```ts
+// Tab indentation
+JSON5.stringify(data, null, "\t");
+```
+
+#### Special Values
+
+Unlike `JSON.stringify`, `JSON5.stringify` preserves special numeric values:
+
+```ts
+JSON5.stringify({ inf: Infinity, ninf: -Infinity, nan: NaN });
+// {inf:Infinity,ninf:-Infinity,nan:NaN}
+```
+
+---
+
+## Module Import
+
+### ES Modules
+
+You can import JSON5 files directly as ES modules:
+
+```json5 config.json5
+{
+ // Database configuration
+ database: {
+ host: "localhost",
+ port: 5432,
+ name: "myapp",
+ },
+
+ features: {
+ auth: true,
+ rateLimit: true,
+ analytics: false,
+ },
+}
+```
+
+#### Default Import
+
+```ts app.ts icon="/icons/typescript.svg"
+import config from "./config.json5";
+
+console.log(config.database.host); // "localhost"
+console.log(config.features.auth); // true
+```
+
+#### Named Imports
+
+You can destructure top-level properties as named imports:
+
+```ts app.ts icon="/icons/typescript.svg"
+import { database, features } from "./config.json5";
+
+console.log(database.host); // "localhost"
+console.log(features.rateLimit); // true
+```
+
+### CommonJS
+
+JSON5 files can also be required in CommonJS:
+
+```ts app.ts icon="/icons/typescript.svg"
+const config = require("./config.json5");
+console.log(config.database.name); // "myapp"
+
+// Destructuring also works
+const { database, features } = require("./config.json5");
+```
+
+---
+
+## Hot Reloading with JSON5
+
+When you run your application with `bun --hot`, changes to JSON5 files are automatically detected and reloaded:
+
+```json5 config.json5
+{
+ server: {
+ port: 3000,
+ host: "localhost",
+ },
+ features: {
+ debug: true,
+ verbose: false,
+ },
+}
+```
+
+```ts server.ts icon="/icons/typescript.svg"
+import { server, features } from "./config.json5";
+
+Bun.serve({
+ port: server.port,
+ hostname: server.host,
+ fetch(req) {
+ if (features.verbose) {
+ console.log(`${req.method} ${req.url}`);
+ }
+ return new Response("Hello World");
+ },
+});
+```
+
+Run with hot reloading:
+
+```bash terminal icon="terminal"
+bun --hot server.ts
+```
+
+---
+
+## Bundler Integration
+
+When you import JSON5 files and bundle with Bun, the JSON5 is parsed at build time and included as a JavaScript module:
+
+```bash terminal icon="terminal"
+bun build app.ts --outdir=dist
+```
+
+This means:
+
+- Zero runtime JSON5 parsing overhead in production
+- Smaller bundle sizes
+- Tree-shaking support for unused properties (named imports)
+
+### Dynamic Imports
+
+JSON5 files can be dynamically imported:
+
+```ts
+const config = await import("./config.json5");
+```
diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts
index d49a78e62f..6098ea4db1 100644
--- a/packages/bun-types/bun.d.ts
+++ b/packages/bun-types/bun.d.ts
@@ -905,6 +905,69 @@ declare module "bun" {
export function stringify(input: unknown, replacer?: undefined | null, space?: string | number): string;
}
+ /**
+ * JSON5 related APIs
+ */
+ namespace JSON5 {
+ /**
+ * Parse a JSON5 string into a JavaScript value.
+ *
+ * JSON5 is a superset of JSON based on ECMAScript 5.1 that supports
+ * comments, trailing commas, unquoted keys, single-quoted strings,
+ * hex numbers, Infinity, NaN, and more.
+ *
+ * @category Utilities
+ *
+ * @param input The JSON5 string to parse
+ * @returns A JavaScript value
+ *
+ * @example
+ * ```ts
+ * import { JSON5 } from "bun";
+ *
+ * const result = JSON5.parse(`{
+ * // This is a comment
+ * name: 'my-app',
+ * version: '1.0.0', // trailing comma is allowed
+ * hex: 0xDEADbeef,
+ * half: .5,
+ * infinity: Infinity,
+ * }`);
+ * ```
+ */
+ export function parse(input: string): unknown;
+
+ /**
+ * Convert a JavaScript value into a JSON5 string. Object keys that are
+ * valid identifiers are unquoted, strings use double quotes, `Infinity`
+ * and `NaN` are represented as literals, and indented output includes
+ * trailing commas.
+ *
+ * @category Utilities
+ *
+ * @param input The JavaScript value to stringify.
+ * @param replacer Currently not supported.
+ * @param space A number for how many spaces each level of indentation gets, or a string used as indentation.
+ * The number is clamped between 0 and 10, and the first 10 characters of the string are used.
+ * @returns A JSON5 string, or `undefined` if the input is `undefined`, a function, or a symbol.
+ *
+ * @example
+ * ```ts
+ * import { JSON5 } from "bun";
+ *
+ * console.log(JSON5.stringify({ a: 1, b: "two" }));
+ * // {a:1,b:"two"}
+ *
+ * console.log(JSON5.stringify({ a: 1, b: 2 }, null, 2));
+ * // {
+ * // a: 1,
+ * // b: 2,
+ * // }
+ * ```
+ */
+ export function stringify(input: unknown, replacer?: undefined | null, space?: string | number): string | undefined;
+ }
+
/**
* Synchronously resolve a `moduleId` as though it were imported from `parent`
*
diff --git a/packages/bun-types/extensions.d.ts b/packages/bun-types/extensions.d.ts
index b88d9c13c0..07e7f3a630 100644
--- a/packages/bun-types/extensions.d.ts
+++ b/packages/bun-types/extensions.d.ts
@@ -23,6 +23,11 @@ declare module "*.jsonc" {
export = contents;
}
+declare module "*.json5" {
+ var contents: any;
+ export = contents;
+}
+
declare module "*/bun.lock" {
var contents: import("bun").BunLockFile;
export = contents;
diff --git a/src/api/schema.zig b/src/api/schema.zig
index 46403ac901..c07747d7b5 100644
--- a/src/api/schema.zig
+++ b/src/api/schema.zig
@@ -343,6 +343,7 @@ pub const api = struct {
sqlite_embedded = 17,
html = 18,
yaml = 19,
+ json5 = 20,
_,
pub fn jsonStringify(self: @This(), writer: anytype) !void {
diff --git a/src/bake/DevServer/DirectoryWatchStore.zig b/src/bake/DevServer/DirectoryWatchStore.zig
index 9efbc3612e..5e4df54fba 100644
--- a/src/bake/DevServer/DirectoryWatchStore.zig
+++ b/src/bake/DevServer/DirectoryWatchStore.zig
@@ -48,6 +48,7 @@ pub fn trackResolutionFailure(store: *DirectoryWatchStore, import_source: []cons
.jsonc,
.toml,
.yaml,
+ .json5,
.wasm,
.napi,
.base64,
diff --git a/src/bun.js/ModuleLoader.zig b/src/bun.js/ModuleLoader.zig
index 515906df97..16eb26e874 100644
--- a/src/bun.js/ModuleLoader.zig
+++ b/src/bun.js/ModuleLoader.zig
@@ -100,7 +100,7 @@ pub fn transpileSourceCode(
const disable_transpilying = comptime flags.disableTranspiling();
if (comptime disable_transpilying) {
- if (!(loader.isJavaScriptLike() or loader == .toml or loader == .yaml or loader == .text or loader == .json or loader == .jsonc)) {
+ if (!(loader.isJavaScriptLike() or loader == .toml or loader == .yaml or loader == .json5 or loader == .text or loader == .json or loader == .jsonc)) {
// Don't print "export default "
return ResolvedSource{
.allocator = null,
@@ -112,7 +112,7 @@ pub fn transpileSourceCode(
}
switch (loader) {
- .js, .jsx, .ts, .tsx, .json, .jsonc, .toml, .yaml, .text => {
+ .js, .jsx, .ts, .tsx, .json, .jsonc, .toml, .yaml, .json5, .text => {
// Ensure that if there was an ASTMemoryAllocator in use, it's not used anymore.
var ast_scope = js_ast.ASTMemoryAllocator.Scope{};
ast_scope.enter();
@@ -361,7 +361,7 @@ pub fn transpileSourceCode(
};
}
- if (loader == .json or loader == .jsonc or loader == .toml or loader == .yaml) {
+ if (loader == .json or loader == .jsonc or loader == .toml or loader == .yaml or loader == .json5) {
if (parse_result.empty) {
return ResolvedSource{
.allocator = null,
diff --git a/src/bun.js/api.zig b/src/bun.js/api.zig
index e09d9c6bfc..0913100b47 100644
--- a/src/bun.js/api.zig
+++ b/src/bun.js/api.zig
@@ -29,6 +29,7 @@ pub const HashObject = @import("./api/HashObject.zig");
pub const JSONCObject = @import("./api/JSONCObject.zig");
pub const TOMLObject = @import("./api/TOMLObject.zig");
pub const UnsafeObject = @import("./api/UnsafeObject.zig");
+pub const JSON5Object = @import("./api/JSON5Object.zig");
pub const YAMLObject = @import("./api/YAMLObject.zig");
pub const Timer = @import("./api/Timer.zig");
pub const FFIObject = @import("./api/FFIObject.zig");
diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig
index be6a22d4dd..62c35be380 100644
--- a/src/bun.js/api/BunObject.zig
+++ b/src/bun.js/api/BunObject.zig
@@ -64,6 +64,7 @@ pub const BunObject = struct {
pub const SHA512_256 = toJSLazyPropertyCallback(Crypto.SHA512_256.getter);
pub const JSONC = toJSLazyPropertyCallback(Bun.getJSONCObject);
pub const TOML = toJSLazyPropertyCallback(Bun.getTOMLObject);
+ pub const JSON5 = toJSLazyPropertyCallback(Bun.getJSON5Object);
pub const YAML = toJSLazyPropertyCallback(Bun.getYAMLObject);
pub const Transpiler = toJSLazyPropertyCallback(Bun.getTranspilerConstructor);
pub const argv = toJSLazyPropertyCallback(Bun.getArgv);
@@ -131,6 +132,7 @@ pub const BunObject = struct {
@export(&BunObject.SHA512_256, .{ .name = lazyPropertyCallbackName("SHA512_256") });
@export(&BunObject.JSONC, .{ .name = lazyPropertyCallbackName("JSONC") });
@export(&BunObject.TOML, .{ .name = lazyPropertyCallbackName("TOML") });
+ @export(&BunObject.JSON5, .{ .name = lazyPropertyCallbackName("JSON5") });
@export(&BunObject.YAML, .{ .name = lazyPropertyCallbackName("YAML") });
@export(&BunObject.Glob, .{ .name = lazyPropertyCallbackName("Glob") });
@export(&BunObject.Transpiler, .{ .name = lazyPropertyCallbackName("Transpiler") });
@@ -1269,6 +1271,10 @@ pub fn getTOMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSVa
return TOMLObject.create(globalThis);
}
+pub fn getJSON5Object(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
+ return JSON5Object.create(globalThis);
+}
+
pub fn getYAMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return YAMLObject.create(globalThis);
}
@@ -2066,6 +2072,7 @@ const gen = bun.gen.BunObject;
const api = bun.api;
const FFIObject = bun.api.FFIObject;
const HashObject = bun.api.HashObject;
+const JSON5Object = bun.api.JSON5Object;
const JSONCObject = bun.api.JSONCObject;
const TOMLObject = bun.api.TOMLObject;
const UnsafeObject = bun.api.UnsafeObject;
diff --git a/src/bun.js/api/JSON5Object.zig b/src/bun.js/api/JSON5Object.zig
new file mode 100644
index 0000000000..945f235507
--- /dev/null
+++ b/src/bun.js/api/JSON5Object.zig
@@ -0,0 +1,433 @@
+pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue {
+ const object = JSValue.createEmptyObject(globalThis, 2);
+ object.put(
+ globalThis,
+ ZigString.static("parse"),
+ jsc.JSFunction.create(globalThis, "parse", parse, 1, .{}),
+ );
+ object.put(
+ globalThis,
+ ZigString.static("stringify"),
+ jsc.JSFunction.create(globalThis, "stringify", stringify, 3, .{}),
+ );
+ return object;
+}
+
+pub fn stringify(
+ global: *jsc.JSGlobalObject,
+ callFrame: *jsc.CallFrame,
+) bun.JSError!jsc.JSValue {
+ const value, const replacer, const space_value = callFrame.argumentsAsArray(3);
+
+ value.ensureStillAlive();
+
+ if (value.isUndefined() or value.isSymbol() or value.isFunction()) {
+ return .js_undefined;
+ }
+
+ if (!replacer.isUndefinedOrNull()) {
+ return global.throw("JSON5.stringify does not support the replacer argument", .{});
+ }
+
+ var stringifier: Stringifier = try .init(global, space_value);
+ defer stringifier.deinit();
+
+ stringifier.stringifyValue(global, value) catch |err| return switch (err) {
+ error.OutOfMemory, error.JSError, error.JSTerminated => |js_err| js_err,
+ error.StackOverflow => global.throwStackOverflow(),
+ };
+
+ return stringifier.builder.toString(global);
+}
+
+pub fn parse(
+ global: *jsc.JSGlobalObject,
+ callFrame: *jsc.CallFrame,
+) bun.JSError!jsc.JSValue {
+ var arena: bun.ArenaAllocator = .init(bun.default_allocator);
+ defer arena.deinit();
+ const allocator = arena.allocator();
+
+ var ast_memory_allocator = bun.handleOom(allocator.create(ast.ASTMemoryAllocator));
+ var ast_scope = ast_memory_allocator.enter(allocator);
+ defer ast_scope.exit();
+
+ const input_value = callFrame.argument(0);
+
+ if (input_value.isEmptyOrUndefinedOrNull()) {
+ return global.throwInvalidArguments("Expected a string to parse", .{});
+ }
+
+ const input: jsc.Node.BlobOrStringOrBuffer =
+ try jsc.Node.BlobOrStringOrBuffer.fromJS(global, allocator, input_value) orelse input: {
+ var str = try input_value.toBunString(global);
+ defer str.deref();
+ break :input .{ .string_or_buffer = .{ .string = str.toSlice(allocator) } };
+ };
+ defer input.deinit();
+
+ var log = logger.Log.init(bun.default_allocator);
+ defer log.deinit();
+
+ const source = &logger.Source.initPathString("input.json5", input.slice());
+
+ const root = json5.JSON5Parser.parse(source, &log, allocator) catch |err| return switch (err) {
+ error.OutOfMemory => |oom| oom,
+ error.StackOverflow => global.throwStackOverflow(),
+ else => {
+ if (log.msgs.items.len > 0) {
+ const first_msg = log.msgs.items[0];
+ return global.throwValue(global.createSyntaxErrorInstance(
+ "JSON5 Parse error: {s}",
+ .{first_msg.data.text},
+ ));
+ }
+ return global.throwValue(global.createSyntaxErrorInstance(
+ "JSON5 Parse error: Unable to parse JSON5 string",
+ .{},
+ ));
+ },
+ };
+
+ return exprToJS(root, global);
+}
+
+const Stringifier = struct {
+ stack_check: bun.StackCheck,
+ builder: wtf.StringBuilder,
+ indent: usize,
+ space: Space,
+ visiting: std.AutoHashMapUnmanaged(JSValue, void),
+ allocator: std.mem.Allocator,
+
+ const StringifyError = bun.JSError || bun.StackOverflow;
+
+ const Space = union(enum) {
+ minified,
+ number: u32,
+ str: bun.String,
+
+ pub fn init(global: *jsc.JSGlobalObject, space_value: JSValue) bun.JSError!Space {
+ const space = try space_value.unwrapBoxedPrimitive(global);
+ if (space.isNumber()) {
+ // Clamp on the float to match the spec's min(10, ToIntegerOrInfinity(space)).
+ // toInt32() wraps large values and Infinity to 0, which is wrong.
+ const num_f = space.asNumber();
+ if (!(num_f >= 1)) return .minified; // handles NaN, -Infinity, 0, negatives
+ return .{ .number = if (num_f > 10) 10 else @intFromFloat(num_f) };
+ }
+ if (space.isString()) {
+ const str = try space.toBunString(global);
+ if (str.length() == 0) {
+ str.deref();
+ return .minified;
+ }
+ return .{ .str = str };
+ }
+ return .minified;
+ }
+
+ pub fn deinit(this: *const Space) void {
+ switch (this.*) {
+ .str => |str| str.deref(),
+ .minified, .number => {},
+ }
+ }
+ };
+
+ pub fn init(global: *jsc.JSGlobalObject, space_value: JSValue) bun.JSError!Stringifier {
+ return .{
+ .stack_check = .init(),
+ .builder = .init(),
+ .indent = 0,
+ .space = try Space.init(global, space_value),
+ .visiting = .empty,
+ .allocator = bun.default_allocator,
+ };
+ }
+
+ pub fn deinit(this: *Stringifier) void {
+ this.builder.deinit();
+ this.space.deinit();
+ this.visiting.deinit(this.allocator);
+ }
+
+ pub fn stringifyValue(this: *Stringifier, global: *jsc.JSGlobalObject, value: JSValue) StringifyError!void {
+ if (!this.stack_check.isSafeToRecurse()) {
+ return error.StackOverflow;
+ }
+
+ const unwrapped = try value.unwrapBoxedPrimitive(global);
+
+ if (unwrapped.isNull()) {
+ this.builder.append(.latin1, "null");
+ return;
+ }
+
+ if (unwrapped.isNumber()) {
+ if (unwrapped.isInt32()) {
+ this.builder.append(.int, unwrapped.asInt32());
+ return;
+ }
+ const num = unwrapped.asNumber();
+ if (std.math.isNegativeInf(num)) {
+ this.builder.append(.latin1, "-Infinity");
+ } else if (std.math.isInf(num)) {
+ this.builder.append(.latin1, "Infinity");
+ } else if (std.math.isNan(num)) {
+ this.builder.append(.latin1, "NaN");
+ } else {
+ this.builder.append(.double, num);
+ }
+ return;
+ }
+
+ if (unwrapped.isBigInt()) {
+ return global.throw("JSON5.stringify cannot serialize BigInt", .{});
+ }
+
+ if (unwrapped.isBoolean()) {
+ this.builder.append(.latin1, if (unwrapped.asBoolean()) "true" else "false");
+ return;
+ }
+
+ if (unwrapped.isString()) {
+ const str = try unwrapped.toBunString(global);
+ defer str.deref();
+ this.appendQuotedString(str);
+ return;
+ }
+
+ // Object or array — check for circular references
+ const gop = try this.visiting.getOrPut(this.allocator, unwrapped);
+ if (gop.found_existing) {
+ return global.throw("Converting circular structure to JSON5", .{});
+ }
+ defer _ = this.visiting.remove(unwrapped);
+
+ if (unwrapped.isArray()) {
+ try this.stringifyArray(global, unwrapped);
+ } else {
+ try this.stringifyObject(global, unwrapped);
+ }
+ }
+
+ fn stringifyArray(this: *Stringifier, global: *jsc.JSGlobalObject, value: JSValue) StringifyError!void {
+ var iter = try value.arrayIterator(global);
+
+ if (iter.len == 0) {
+ this.builder.append(.latin1, "[]");
+ return;
+ }
+
+ this.builder.append(.lchar, '[');
+
+ switch (this.space) {
+ .minified => {
+ var first = true;
+ while (try iter.next()) |item| {
+ if (!first) this.builder.append(.lchar, ',');
+ first = false;
+ if (item.isUndefined() or item.isSymbol() or item.isFunction()) {
+ this.builder.append(.latin1, "null");
+ } else {
+ try this.stringifyValue(global, item);
+ }
+ }
+ },
+ .number, .str => {
+ this.indent += 1;
+ var first = true;
+ while (try iter.next()) |item| {
+ if (!first) this.builder.append(.lchar, ',');
+ first = false;
+ this.newline();
+ if (item.isUndefined() or item.isSymbol() or item.isFunction()) {
+ this.builder.append(.latin1, "null");
+ } else {
+ try this.stringifyValue(global, item);
+ }
+ }
+ // Trailing comma
+ this.builder.append(.lchar, ',');
+ this.indent -= 1;
+ this.newline();
+ },
+ }
+
+ this.builder.append(.lchar, ']');
+ }
+
+ fn stringifyObject(this: *Stringifier, global: *jsc.JSGlobalObject, value: JSValue) StringifyError!void {
+ var iter: jsc.JSPropertyIterator(.{ .skip_empty_name = false, .include_value = true }) = try .init(
+ global,
+ try value.toObject(global),
+ );
+ defer iter.deinit();
+
+ if (iter.len == 0) {
+ this.builder.append(.latin1, "{}");
+ return;
+ }
+
+ this.builder.append(.lchar, '{');
+
+ switch (this.space) {
+ .minified => {
+ var first = true;
+ while (try iter.next()) |prop_name| {
+ if (iter.value.isUndefined() or iter.value.isSymbol() or iter.value.isFunction()) {
+ continue;
+ }
+ if (!first) this.builder.append(.lchar, ',');
+ first = false;
+ this.appendKey(prop_name);
+ this.builder.append(.lchar, ':');
+ try this.stringifyValue(global, iter.value);
+ }
+ },
+ .number, .str => {
+ this.indent += 1;
+ var first = true;
+ while (try iter.next()) |prop_name| {
+ if (iter.value.isUndefined() or iter.value.isSymbol() or iter.value.isFunction()) {
+ continue;
+ }
+ if (!first) this.builder.append(.lchar, ',');
+ first = false;
+ this.newline();
+ this.appendKey(prop_name);
+ this.builder.append(.latin1, ": ");
+ try this.stringifyValue(global, iter.value);
+ }
+ this.indent -= 1;
+ if (!first) {
+ // Trailing comma
+ this.builder.append(.lchar, ',');
+ this.newline();
+ }
+ },
+ }
+
+ this.builder.append(.lchar, '}');
+ }
+
+ fn appendKey(this: *Stringifier, name: bun.String) void {
+ const is_identifier = is_identifier: {
+ if (name.length() == 0) break :is_identifier false;
+ if (!bun.js_lexer.isIdentifierStart(@intCast(name.charAt(0)))) break :is_identifier false;
+ for (1..name.length()) |i| {
+ if (!bun.js_lexer.isIdentifierContinue(@intCast(name.charAt(i)))) break :is_identifier false;
+ }
+ break :is_identifier true;
+ };
+
+ if (is_identifier) {
+ this.builder.append(.string, name);
+ } else {
+ this.appendQuotedString(name);
+ }
+ }
+
+ fn appendQuotedString(this: *Stringifier, str: bun.String) void {
+ this.builder.append(.lchar, '\'');
+ for (0..str.length()) |i| {
+ const c = str.charAt(i);
+ switch (c) {
+ 0x00 => this.builder.append(.latin1, "\\0"),
+ 0x08 => this.builder.append(.latin1, "\\b"),
+ 0x09 => this.builder.append(.latin1, "\\t"),
+ 0x0a => this.builder.append(.latin1, "\\n"),
+ 0x0b => this.builder.append(.latin1, "\\v"),
+ 0x0c => this.builder.append(.latin1, "\\f"),
+ 0x0d => this.builder.append(.latin1, "\\r"),
+ 0x27 => this.builder.append(.latin1, "\\'"), // single quote
+ 0x5c => this.builder.append(.latin1, "\\\\"), // backslash
+ 0x2028 => this.builder.append(.latin1, "\\u2028"),
+ 0x2029 => this.builder.append(.latin1, "\\u2029"),
+ 0x01...0x07, 0x0e...0x1f, 0x7f => {
+ // Other control chars → \xHH
+ this.builder.append(.latin1, "\\x");
+ this.builder.append(.lchar, hexDigit(c >> 4));
+ this.builder.append(.lchar, hexDigit(c & 0x0f));
+ },
+ else => this.builder.append(.uchar, c),
+ }
+ }
+ this.builder.append(.lchar, '\'');
+ }
+
+ fn hexDigit(v: u16) u8 {
+ const nibble: u8 = @intCast(v & 0x0f);
+ return if (nibble < 10) '0' + nibble else 'a' + nibble - 10;
+ }
+
+ fn newline(this: *Stringifier) void {
+ switch (this.space) {
+ .minified => {},
+ .number => |space_num| {
+ this.builder.append(.lchar, '\n');
+ for (0..this.indent * space_num) |_| {
+ this.builder.append(.lchar, ' ');
+ }
+ },
+ .str => |space_str| {
+ this.builder.append(.lchar, '\n');
+ const clamped = if (space_str.length() > 10)
+ space_str.substringWithLen(0, 10)
+ else
+ space_str;
+ for (0..this.indent) |_| {
+ this.builder.append(.string, clamped);
+ }
+ },
+ }
+ }
+};
+
+fn exprToJS(expr: Expr, global: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue {
+ switch (expr.data) {
+ .e_null => return .null,
+ .e_boolean => |boolean| return .jsBoolean(boolean.value),
+ .e_number => |number| return .jsNumber(number.value),
+ .e_string => |str| {
+ return str.toJS(bun.default_allocator, global);
+ },
+ .e_array => |arr| {
+ var js_arr = try JSValue.createEmptyArray(global, arr.items.len);
+ for (arr.slice(), 0..) |item, _i| {
+ const i: u32 = @intCast(_i);
+ const value = try exprToJS(item, global);
+ try js_arr.putIndex(global, i, value);
+ }
+ return js_arr;
+ },
+ .e_object => |obj| {
+ var js_obj = JSValue.createEmptyObject(global, obj.properties.len);
+ for (obj.properties.slice()) |prop| {
+ const key_expr = prop.key.?;
+ const value = try exprToJS(prop.value.?, global);
+ const key_js = try exprToJS(key_expr, global);
+ const key_str = try key_js.toBunString(global);
+ defer key_str.deref();
+ try js_obj.putMayBeIndex(global, &key_str, value);
+ }
+ return js_obj;
+ },
+ else => return .js_undefined,
+ }
+}
+
+const std = @import("std");
+
+const bun = @import("bun");
+const logger = bun.logger;
+const json5 = bun.interchange.json5;
+
+const ast = bun.ast;
+const Expr = ast.Expr;
+
+const jsc = bun.jsc;
+const JSValue = jsc.JSValue;
+const ZigString = jsc.ZigString;
+const wtf = jsc.wtf;
diff --git a/src/bun.js/api/YAMLObject.zig b/src/bun.js/api/YAMLObject.zig
index 0756dc5ecb..1d1e61571b 100644
--- a/src/bun.js/api/YAMLObject.zig
+++ b/src/bun.js/api/YAMLObject.zig
@@ -75,17 +75,17 @@ const Stringifier = struct {
str: String,
pub fn init(global: *JSGlobalObject, space_value: JSValue) JSError!Space {
- if (space_value.isNumber()) {
- var num = space_value.toInt32();
- num = @max(0, @min(num, 10));
- if (num == 0) {
- return .minified;
- }
- return .{ .number = @intCast(num) };
+ const space = try space_value.unwrapBoxedPrimitive(global);
+ if (space.isNumber()) {
+ // Clamp on the float to match the spec's min(10, ToIntegerOrInfinity(space)).
+ // toInt32() wraps large values and Infinity to 0, which is wrong.
+ const num_f = space.asNumber();
+ if (!(num_f >= 1)) return .minified; // handles NaN, -Infinity, 0, negatives
+ return .{ .number = if (num_f > 10) 10 else @intFromFloat(num_f) };
}
- if (space_value.isString()) {
- const str = try space_value.toBunString(global);
+ if (space.isString()) {
+ const str = try space.toBunString(global);
if (str.length() == 0) {
str.deref();
return .minified;
@@ -490,6 +490,9 @@ const Stringifier = struct {
try this.stringify(global, iter.value);
this.indent -= 1;
}
+ if (first) {
+ this.builder.append(.latin1, "{}");
+ }
},
}
}
diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h
index 96f3cd8634..ce5670fa3e 100644
--- a/src/bun.js/bindings/BunObject+exports.h
+++ b/src/bun.js/bindings/BunObject+exports.h
@@ -9,6 +9,7 @@
macro(FFI) \
macro(FileSystemRouter) \
macro(Glob) \
+ macro(JSON5) \
macro(JSONC) \
macro(MD4) \
macro(MD5) \
diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp
index c6d1dc56ea..60c91fa0f0 100644
--- a/src/bun.js/bindings/BunObject.cpp
+++ b/src/bun.js/bindings/BunObject.cpp
@@ -919,6 +919,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
SHA512 BunObject_lazyPropCb_wrap_SHA512 DontDelete|PropertyCallback
SHA512_256 BunObject_lazyPropCb_wrap_SHA512_256 DontDelete|PropertyCallback
JSONC BunObject_lazyPropCb_wrap_JSONC DontDelete|PropertyCallback
+ JSON5 BunObject_lazyPropCb_wrap_JSON5 DontDelete|PropertyCallback
JSONL constructJSONLObject ReadOnly|DontDelete|PropertyCallback
TOML BunObject_lazyPropCb_wrap_TOML DontDelete|PropertyCallback
YAML BunObject_lazyPropCb_wrap_YAML DontDelete|PropertyCallback
diff --git a/src/bun.js/bindings/generated_perf_trace_events.h b/src/bun.js/bindings/generated_perf_trace_events.h
index dd174d2e64..bea83c7545 100644
--- a/src/bun.js/bindings/generated_perf_trace_events.h
+++ b/src/bun.js/bindings/generated_perf_trace_events.h
@@ -2,61 +2,62 @@
// clang-format off
#define FOR_EACH_TRACE_EVENT(macro) \
macro(Bundler.BindImportsToExports, 0) \
- macro(Bundler.CloneLinkerGraph, 1) \
- macro(Bundler.CreateNamespaceExports, 2) \
- macro(Bundler.FigureOutCommonJS, 3) \
- macro(Bundler.MatchImportsWithExports, 4) \
- macro(Bundler.ParseJS, 5) \
- macro(Bundler.ParseJSON, 6) \
- macro(Bundler.ParseTOML, 7) \
- macro(Bundler.ParseYAML, 8) \
- macro(Bundler.ResolveExportStarStatements, 9) \
- macro(Bundler.Worker.create, 10) \
- macro(Bundler.WrapDependencies, 11) \
- macro(Bundler.breakOutputIntoPieces, 12) \
- macro(Bundler.cloneAST, 13) \
- macro(Bundler.computeChunks, 14) \
- macro(Bundler.findAllImportedPartsInJSOrder, 15) \
- macro(Bundler.findReachableFiles, 16) \
- macro(Bundler.generateChunksInParallel, 17) \
- macro(Bundler.generateCodeForFileInChunkCss, 18) \
- macro(Bundler.generateCodeForFileInChunkJS, 19) \
- macro(Bundler.generateIsolatedHash, 20) \
- macro(Bundler.generateSourceMapForChunk, 21) \
- macro(Bundler.markFileLiveForTreeShaking, 22) \
- macro(Bundler.markFileReachableForCodeSplitting, 23) \
- macro(Bundler.onParseTaskComplete, 24) \
- macro(Bundler.postProcessJSChunk, 25) \
- macro(Bundler.readFile, 26) \
- macro(Bundler.renameSymbolsInChunk, 27) \
- macro(Bundler.scanImportsAndExports, 28) \
- macro(Bundler.treeShakingAndCodeSplitting, 29) \
- macro(Bundler.writeChunkToDisk, 30) \
- macro(Bundler.writeOutputFilesToDisk, 31) \
- macro(ExtractTarball.extract, 32) \
- macro(FolderResolver.readPackageJSONFromDisk.folder, 33) \
- macro(FolderResolver.readPackageJSONFromDisk.workspace, 34) \
- macro(JSBundler.addPlugin, 35) \
- macro(JSBundler.hasAnyMatches, 36) \
- macro(JSBundler.matchOnLoad, 37) \
- macro(JSBundler.matchOnResolve, 38) \
- macro(JSGlobalObject.create, 39) \
- macro(JSParser.analyze, 40) \
- macro(JSParser.parse, 41) \
- macro(JSParser.postvisit, 42) \
- macro(JSParser.visit, 43) \
- macro(JSPrinter.print, 44) \
- macro(JSPrinter.printWithSourceMap, 45) \
- macro(ModuleResolver.resolve, 46) \
- macro(PackageInstaller.install, 47) \
- macro(PackageManifest.Serializer.loadByFile, 48) \
- macro(PackageManifest.Serializer.save, 49) \
- macro(RuntimeTranspilerCache.fromFile, 50) \
- macro(RuntimeTranspilerCache.save, 51) \
- macro(RuntimeTranspilerCache.toFile, 52) \
- macro(StandaloneModuleGraph.serialize, 53) \
- macro(Symbols.followAll, 54) \
- macro(TestCommand.printCodeCoverageLCov, 55) \
- macro(TestCommand.printCodeCoverageLCovAndText, 56) \
- macro(TestCommand.printCodeCoverageText, 57) \
+ macro(Bundler.breakOutputIntoPieces, 1) \
+ macro(Bundler.cloneAST, 2) \
+ macro(Bundler.CloneLinkerGraph, 3) \
+ macro(Bundler.computeChunks, 4) \
+ macro(Bundler.CreateNamespaceExports, 5) \
+ macro(Bundler.FigureOutCommonJS, 6) \
+ macro(Bundler.findAllImportedPartsInJSOrder, 7) \
+ macro(Bundler.findReachableFiles, 8) \
+ macro(Bundler.generateChunksInParallel, 9) \
+ macro(Bundler.generateCodeForFileInChunkCss, 10) \
+ macro(Bundler.generateCodeForFileInChunkJS, 11) \
+ macro(Bundler.generateIsolatedHash, 12) \
+ macro(Bundler.generateSourceMapForChunk, 13) \
+ macro(Bundler.markFileLiveForTreeShaking, 14) \
+ macro(Bundler.markFileReachableForCodeSplitting, 15) \
+ macro(Bundler.MatchImportsWithExports, 16) \
+ macro(Bundler.onParseTaskComplete, 17) \
+ macro(Bundler.ParseJS, 18) \
+ macro(Bundler.ParseJSON, 19) \
+ macro(Bundler.ParseJSON5, 20) \
+ macro(Bundler.ParseTOML, 21) \
+ macro(Bundler.ParseYAML, 22) \
+ macro(Bundler.postProcessJSChunk, 23) \
+ macro(Bundler.readFile, 24) \
+ macro(Bundler.renameSymbolsInChunk, 25) \
+ macro(Bundler.ResolveExportStarStatements, 26) \
+ macro(Bundler.scanImportsAndExports, 27) \
+ macro(Bundler.treeShakingAndCodeSplitting, 28) \
+ macro(Bundler.Worker.create, 29) \
+ macro(Bundler.WrapDependencies, 30) \
+ macro(Bundler.writeChunkToDisk, 31) \
+ macro(Bundler.writeOutputFilesToDisk, 32) \
+ macro(ExtractTarball.extract, 33) \
+ macro(FolderResolver.readPackageJSONFromDisk.folder, 34) \
+ macro(FolderResolver.readPackageJSONFromDisk.workspace, 35) \
+ macro(JSBundler.addPlugin, 36) \
+ macro(JSBundler.hasAnyMatches, 37) \
+ macro(JSBundler.matchOnLoad, 38) \
+ macro(JSBundler.matchOnResolve, 39) \
+ macro(JSGlobalObject.create, 40) \
+ macro(JSParser.analyze, 41) \
+ macro(JSParser.parse, 42) \
+ macro(JSParser.postvisit, 43) \
+ macro(JSParser.visit, 44) \
+ macro(JSPrinter.print, 45) \
+ macro(JSPrinter.printWithSourceMap, 46) \
+ macro(ModuleResolver.resolve, 47) \
+ macro(PackageInstaller.install, 48) \
+ macro(PackageManifest.Serializer.loadByFile, 49) \
+ macro(PackageManifest.Serializer.save, 50) \
+ macro(RuntimeTranspilerCache.fromFile, 51) \
+ macro(RuntimeTranspilerCache.save, 52) \
+ macro(RuntimeTranspilerCache.toFile, 53) \
+ macro(StandaloneModuleGraph.serialize, 54) \
+ macro(Symbols.followAll, 55) \
+ macro(TestCommand.printCodeCoverageLCov, 56) \
+ macro(TestCommand.printCodeCoverageLCovAndText, 57) \
+ macro(TestCommand.printCodeCoverageText, 58) \
// end
diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig
index dfdbe2d187..5020967092 100644
--- a/src/bundler/LinkerContext.zig
+++ b/src/bundler/LinkerContext.zig
@@ -505,7 +505,7 @@ pub const LinkerContext = struct {
const loader = loaders[record.source_index.get()];
switch (loader) {
- .jsx, .js, .ts, .tsx, .napi, .sqlite, .json, .jsonc, .yaml, .html, .sqlite_embedded => {
+ .jsx, .js, .ts, .tsx, .napi, .sqlite, .json, .jsonc, .json5, .yaml, .html, .sqlite_embedded => {
log.addErrorFmt(
source,
record.range.loc,
diff --git a/src/bundler/ParseTask.zig b/src/bundler/ParseTask.zig
index 6264f735d0..81f96cc9f7 100644
--- a/src/bundler/ParseTask.zig
+++ b/src/bundler/ParseTask.zig
@@ -360,6 +360,17 @@ fn getAST(
const root = try YAML.parse(source, &temp_log, allocator);
return JSAst.init((try js_parser.newLazyExportAST(allocator, transpiler.options.define, opts, &temp_log, root, source, "")).?);
},
+ .json5 => {
+ const trace = bun.perf.trace("Bundler.ParseJSON5");
+ defer trace.end();
+ var temp_log = bun.logger.Log.init(allocator);
+ defer {
+ bun.handleOom(temp_log.cloneToWithRecycled(log, true));
+ temp_log.msgs.clearAndFree();
+ }
+ const root = try JSON5.parse(source, &temp_log, allocator);
+ return JSAst.init((try js_parser.newLazyExportAST(allocator, transpiler.options.define, opts, &temp_log, root, source, "")).?);
+ },
.text => {
const root = Expr.init(E.String, E.String{
.data = source.contents,
@@ -1428,6 +1439,7 @@ const default_allocator = bun.default_allocator;
const js_parser = bun.js_parser;
const strings = bun.strings;
const BabyList = bun.collections.BabyList;
+const JSON5 = bun.interchange.json5.JSON5Parser;
const TOML = bun.interchange.toml.TOML;
const YAML = bun.interchange.yaml.YAML;
diff --git a/src/generated_perf_trace_events.zig b/src/generated_perf_trace_events.zig
index 6f462c5377..3fd6b53a57 100644
--- a/src/generated_perf_trace_events.zig
+++ b/src/generated_perf_trace_events.zig
@@ -1,20 +1,12 @@
// Generated with scripts/generate-perf-trace-events.sh
pub const PerfEvent = enum(i32) {
@"Bundler.BindImportsToExports",
- @"Bundler.CloneLinkerGraph",
- @"Bundler.CreateNamespaceExports",
- @"Bundler.FigureOutCommonJS",
- @"Bundler.MatchImportsWithExports",
- @"Bundler.ParseJS",
- @"Bundler.ParseJSON",
- @"Bundler.ParseTOML",
- @"Bundler.ParseYAML",
- @"Bundler.ResolveExportStarStatements",
- @"Bundler.Worker.create",
- @"Bundler.WrapDependencies",
@"Bundler.breakOutputIntoPieces",
@"Bundler.cloneAST",
+ @"Bundler.CloneLinkerGraph",
@"Bundler.computeChunks",
+ @"Bundler.CreateNamespaceExports",
+ @"Bundler.FigureOutCommonJS",
@"Bundler.findAllImportedPartsInJSOrder",
@"Bundler.findReachableFiles",
@"Bundler.generateChunksInParallel",
@@ -24,12 +16,21 @@ pub const PerfEvent = enum(i32) {
@"Bundler.generateSourceMapForChunk",
@"Bundler.markFileLiveForTreeShaking",
@"Bundler.markFileReachableForCodeSplitting",
+ @"Bundler.MatchImportsWithExports",
@"Bundler.onParseTaskComplete",
+ @"Bundler.ParseJS",
+ @"Bundler.ParseJSON",
+ @"Bundler.ParseJSON5",
+ @"Bundler.ParseTOML",
+ @"Bundler.ParseYAML",
@"Bundler.postProcessJSChunk",
@"Bundler.readFile",
@"Bundler.renameSymbolsInChunk",
+ @"Bundler.ResolveExportStarStatements",
@"Bundler.scanImportsAndExports",
@"Bundler.treeShakingAndCodeSplitting",
+ @"Bundler.Worker.create",
+ @"Bundler.WrapDependencies",
@"Bundler.writeChunkToDisk",
@"Bundler.writeOutputFilesToDisk",
@"ExtractTarball.extract",
diff --git a/src/interchange.zig b/src/interchange.zig
index 7c9194267c..4c99019466 100644
--- a/src/interchange.zig
+++ b/src/interchange.zig
@@ -1,3 +1,4 @@
pub const json = @import("./interchange/json.zig");
+pub const json5 = @import("./interchange/json5.zig");
pub const toml = @import("./interchange/toml.zig");
pub const yaml = @import("./interchange/yaml.zig");
diff --git a/src/interchange/json5.zig b/src/interchange/json5.zig
new file mode 100644
index 0000000000..09e70e7a35
--- /dev/null
+++ b/src/interchange/json5.zig
@@ -0,0 +1,913 @@
+/// JSON5 Token-Based Scanner/Parser
+///
+/// Parses JSON5 text into Expr AST values. JSON5 is a superset of JSON
+/// based on ECMAScript 5.1 that supports comments, trailing commas,
+/// unquoted keys, single-quoted strings, hex numbers, Infinity, NaN, etc.
+///
+/// Architecture: a scanner reads source bytes and produces typed tokens;
+/// the parser only consumes tokens and never touches source/pos directly.
+///
+/// Reference: https://spec.json5.org/
+pub const JSON5Parser = struct {
+ source: []const u8,
+ pos: usize,
+ allocator: std.mem.Allocator,
+ stack_check: bun.StackCheck,
+ token: Token,
+
+ const Token = struct {
+ loc: logger.Loc,
+ data: Data,
+
+ const Data = union(enum) {
+ eof,
+ // Structural
+ left_brace,
+ right_brace,
+ left_bracket,
+ right_bracket,
+ colon,
+ comma,
+ // Values
+ string: []u8,
+ number: f64,
+ boolean: bool,
+ null,
+ // Identifiers (for unquoted keys that aren't keywords)
+ identifier: []u8,
+
+ fn canStartValue(data: Data) bool {
+ return switch (data) {
+ .string, .number, .boolean, .identifier, .null, .left_brace, .left_bracket => true,
+ .eof, .right_brace, .right_bracket, .colon, .comma => false,
+ };
+ }
+ };
+ };
+
+ const ParseError = OOM || error{
+ UnexpectedCharacter,
+ UnexpectedToken,
+ UnexpectedEof,
+ UnterminatedString,
+ UnterminatedComment,
+ UnterminatedObject,
+ UnterminatedArray,
+ UnterminatedEscape,
+ InvalidNumber,
+ LeadingZeros,
+ InvalidHexNumber,
+ InvalidHexEscape,
+ InvalidUnicodeEscape,
+ OctalEscape,
+ ExpectedColon,
+ ExpectedComma,
+ ExpectedClosingBrace,
+ ExpectedClosingBracket,
+ InvalidIdentifier,
+ TrailingData,
+ StackOverflow,
+ };
+
+ pub const Error = union(enum) {
+ oom,
+ stack_overflow,
+ unexpected_character: struct { pos: usize },
+ unexpected_token: struct { pos: usize },
+ unexpected_eof: struct { pos: usize },
+ unterminated_string: struct { pos: usize },
+ unterminated_comment: struct { pos: usize },
+ unterminated_object: struct { pos: usize },
+ unterminated_array: struct { pos: usize },
+ unterminated_escape: struct { pos: usize },
+ invalid_number: struct { pos: usize },
+ leading_zeros: struct { pos: usize },
+ invalid_hex_number: struct { pos: usize },
+ invalid_hex_escape: struct { pos: usize },
+ invalid_unicode_escape: struct { pos: usize },
+ octal_escape: struct { pos: usize },
+ expected_colon: struct { pos: usize },
+ expected_comma: struct { pos: usize },
+ expected_closing_brace: struct { pos: usize },
+ expected_closing_bracket: struct { pos: usize },
+ invalid_identifier: struct { pos: usize },
+ trailing_data: struct { pos: usize },
+
+ pub fn addToLog(this: *const Error, source: *const logger.Source, log: *logger.Log) (OOM || error{StackOverflow})!void {
+ const loc: logger.Loc = switch (this.*) {
+ .oom => return error.OutOfMemory,
+ .stack_overflow => return error.StackOverflow,
+ inline else => |e| .{ .start = @intCast(e.pos) },
+ };
+ const msg: []const u8 = switch (this.*) {
+ .oom, .stack_overflow => unreachable,
+ .unexpected_character => "Unexpected character",
+ .unexpected_token => "Unexpected token",
+ .unexpected_eof => "Unexpected end of input",
+ .unterminated_string => "Unterminated string",
+ .unterminated_comment => "Unterminated multi-line comment",
+ .unterminated_object => "Unterminated object",
+ .unterminated_array => "Unterminated array",
+ .unterminated_escape => "Unexpected end of input in escape sequence",
+ .invalid_number => "Invalid number",
+ .leading_zeros => "Leading zeros are not allowed in JSON5",
+ .invalid_hex_number => "Invalid hex number",
+ .invalid_hex_escape => "Invalid hex escape",
+ .invalid_unicode_escape => "Invalid unicode escape: expected 4 hex digits",
+ .octal_escape => "Octal escape sequences are not allowed in JSON5",
+ .expected_colon => "Expected ':' after object key",
+ .expected_comma => "Expected ','",
+ .expected_closing_brace => "Expected '}'",
+ .expected_closing_bracket => "Expected ']'",
+ .invalid_identifier => "Invalid identifier start character",
+ .trailing_data => "Unexpected token after JSON5 value",
+ };
+ try log.addError(source, loc, msg);
+ }
+ };
+
+ fn toError(err: ParseError, parser: *const JSON5Parser) Error {
+ const token_pos = parser.token.loc.toUsize();
+ const scan_pos = parser.pos;
+ return switch (err) {
+ error.OutOfMemory => .oom,
+ error.StackOverflow => .stack_overflow,
+ // Scanner errors use scan position
+ error.UnexpectedCharacter => .{ .unexpected_character = .{ .pos = scan_pos } },
+ error.UnterminatedString => .{ .unterminated_string = .{ .pos = scan_pos } },
+ error.UnterminatedComment => .{ .unterminated_comment = .{ .pos = scan_pos } },
+ error.UnterminatedEscape => .{ .unterminated_escape = .{ .pos = scan_pos } },
+ error.InvalidNumber => .{ .invalid_number = .{ .pos = scan_pos } },
+ error.LeadingZeros => .{ .leading_zeros = .{ .pos = scan_pos } },
+ error.InvalidHexNumber => .{ .invalid_hex_number = .{ .pos = scan_pos } },
+ error.InvalidHexEscape => .{ .invalid_hex_escape = .{ .pos = scan_pos } },
+ error.InvalidUnicodeEscape => .{ .invalid_unicode_escape = .{ .pos = scan_pos } },
+ error.OctalEscape => .{ .octal_escape = .{ .pos = scan_pos } },
+ error.InvalidIdentifier => .{ .invalid_identifier = .{ .pos = scan_pos } },
+ // Parser errors use token position
+ error.UnexpectedToken => .{ .unexpected_token = .{ .pos = token_pos } },
+ error.UnexpectedEof => if (parser.token.data == .eof)
+ .{ .unexpected_eof = .{ .pos = token_pos } }
+ else
+ .{ .unexpected_token = .{ .pos = token_pos } },
+ error.TrailingData => .{ .trailing_data = .{ .pos = token_pos } },
+ error.ExpectedColon => .{ .expected_colon = .{ .pos = token_pos } },
+ error.UnterminatedObject => .{ .unterminated_object = .{ .pos = token_pos } },
+ error.ExpectedComma => .{ .expected_comma = .{ .pos = token_pos } },
+ error.ExpectedClosingBrace => .{ .expected_closing_brace = .{ .pos = token_pos } },
+ error.UnterminatedArray => .{ .unterminated_array = .{ .pos = token_pos } },
+ error.ExpectedClosingBracket => .{ .expected_closing_bracket = .{ .pos = token_pos } },
+ };
+ }
+
+ const ExternalError = OOM || error{ SyntaxError, StackOverflow };
+
+ pub fn parse(source: *const logger.Source, log: *logger.Log, allocator: std.mem.Allocator) ExternalError!Expr {
+ var parser = JSON5Parser{
+ .source = source.contents,
+ .pos = 0,
+ .allocator = allocator,
+ .stack_check = .init(),
+ .token = .{ .loc = .{}, .data = .eof },
+ };
+ const result = parser.parseRoot() catch |err| {
+ const e = toError(err, &parser);
+ try e.addToLog(source, log);
+ return error.SyntaxError;
+ };
+ return result;
+ }
+
+ // ── Scanner ──
+
+ /// Returns the byte at the current position, or 0 if at EOF.
+ /// All source access in scan() goes through this to avoid bounds checks.
+ fn peek(self: *const JSON5Parser) u8 {
+ if (self.pos < self.source.len) return self.source[self.pos];
+ return 0;
+ }
+
+ fn scan(self: *JSON5Parser) ParseError!void {
+ self.token.data = next: switch (self.peek()) {
+ 0 => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next .eof;
+ },
+ // Whitespace — skip without setting loc
+ '\t', '\n', '\r', ' ', 0x0B, 0x0C => {
+ self.pos += 1;
+ continue :next self.peek();
+ },
+ // Structural
+ '{' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .left_brace;
+ },
+ '}' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .right_brace;
+ },
+ '[' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .left_bracket;
+ },
+ ']' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .right_bracket;
+ },
+ ':' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .colon;
+ },
+ ',' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .comma;
+ },
+ '+' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .{ .number = try self.scanSignedValue(false) };
+ },
+ '-' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ self.pos += 1;
+ break :next .{ .number = try self.scanSignedValue(true) };
+ },
+ // Strings
+ '"', '\'' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next .{ .string = try self.scanString() };
+ },
+ // Numbers
+ '0'...'9', '.' => {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next .{ .number = try self.scanNumber() };
+ },
+ // Comments — skip without setting loc
+ '/' => {
+ const n = if (self.pos + 1 < self.source.len) self.source[self.pos + 1] else 0;
+ if (n == '/') {
+ self.pos += 2;
+ self.skipToEndOfLine();
+ continue :next self.peek();
+ } else if (n == '*') {
+ self.pos += 2;
+ try self.skipBlockComment();
+ continue :next self.peek();
+ }
+ return error.UnexpectedCharacter;
+ },
+ else => |c| {
+ if (c == 't') {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next if (self.scanKeyword("true")) .{ .boolean = true } else .{ .identifier = try self.scanIdentifier() };
+ } else if (c == 'f') {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next if (self.scanKeyword("false")) .{ .boolean = false } else .{ .identifier = try self.scanIdentifier() };
+ } else if (c == 'n') {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next if (self.scanKeyword("null")) .null else .{ .identifier = try self.scanIdentifier() };
+ } else if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_' or c == '$' or c == '\\') {
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ break :next .{ .identifier = try self.scanIdentifier() };
+ } else if (c >= 0x80) {
+ // Multi-byte: check whitespace first, then identifier
+ const mb = self.multiByteWhitespace();
+ if (mb > 0) {
+ self.pos += mb;
+ continue :next self.peek();
+ }
+ self.token.loc = .{ .start = @intCast(self.pos) };
+ const cp = self.readCodepoint() orelse {
+ return error.UnexpectedCharacter;
+ };
+ if (identifier.isIdentifierStart(cp.cp)) {
+ break :next .{ .identifier = try self.scanIdentifier() };
+ } else {
+ return error.UnexpectedCharacter;
+ }
+ } else {
+ return error.UnexpectedCharacter;
+ }
+ },
+ };
+ }
+
+ fn scanKeyword(self: *JSON5Parser, comptime keyword: []const u8) bool {
+ if (self.pos + keyword.len > self.source.len) return false;
+ if (!std.mem.eql(u8, self.source[self.pos..][0..keyword.len], keyword)) return false;
+ // Check word boundary
+ if (self.pos + keyword.len < self.source.len) {
+ const next = self.source[self.pos + keyword.len];
+ if (isIdentContinueASCII(next) or next == '\\' or next >= 0x80) return false;
+ }
+ self.pos += keyword.len;
+ return true;
+ }
+
+ fn scanSignedValue(self: *JSON5Parser, is_negative: bool) ParseError!f64 {
+ switch (self.peek()) {
+ '0'...'9', '.' => {
+ const n = try self.scanNumber();
+ return if (is_negative) -n else n;
+ },
+ 'I' => {
+ if (self.scanKeyword("Infinity")) {
+ return if (is_negative) -std.math.inf(f64) else std.math.inf(f64);
+ }
+ return error.UnexpectedCharacter;
+ },
+ 'N' => {
+ if (self.scanKeyword("NaN")) {
+ const nan = std.math.nan(f64);
+ return if (is_negative) -nan else nan;
+ }
+ return error.UnexpectedCharacter;
+ },
+ 0 => return error.UnexpectedEof,
+ else => return error.UnexpectedCharacter,
+ }
+ }
+
+ // ── Parser ──
+
+ fn parseRoot(self: *JSON5Parser) ParseError!Expr {
+ try self.scan();
+ const result = try self.parseValue();
+ if (self.token.data != .eof) {
+ return error.TrailingData;
+ }
+ return result;
+ }
+
+ fn parseValue(self: *JSON5Parser) ParseError!Expr {
+ if (!self.stack_check.isSafeToRecurse()) {
+ return error.StackOverflow;
+ }
+
+ const loc = self.token.loc;
+
+ switch (self.token.data) {
+ .left_brace => return self.parseObject(),
+ .left_bracket => return self.parseArray(),
+ .string => |s| {
+ try self.scan();
+ return Expr.init(E.String, E.String.init(s), loc);
+ },
+ .number => |n| {
+ try self.scan();
+ return Expr.init(E.Number, .{ .value = n }, loc);
+ },
+ .boolean => |b| {
+ try self.scan();
+ return Expr.init(E.Boolean, .{ .value = b }, loc);
+ },
+ .null => {
+ try self.scan();
+ return Expr.init(E.Null, .{}, loc);
+ },
+ .identifier => |s| {
+ if (std.mem.eql(u8, s, "NaN")) {
+ try self.scan();
+ return Expr.init(E.Number, .{ .value = std.math.nan(f64) }, loc);
+ } else if (std.mem.eql(u8, s, "Infinity")) {
+ try self.scan();
+ return Expr.init(E.Number, .{ .value = std.math.inf(f64) }, loc);
+ }
+ return error.UnexpectedToken;
+ },
+ .eof => return error.UnexpectedEof,
+ else => return error.UnexpectedToken,
+ }
+ }
+
+ fn parseObject(self: *JSON5Parser) ParseError!Expr {
+ const loc = self.token.loc;
+ try self.scan(); // advance past '{'
+
+ var properties = std.array_list.Managed(G.Property).init(self.allocator);
+
+ while (self.token.data != .right_brace) {
+ const key = try self.parseObjectKey();
+
+ if (self.token.data != .colon) {
+ return error.ExpectedColon;
+ }
+ try self.scan(); // advance past ':'
+
+ const value = try self.parseValue();
+
+ try properties.append(.{
+ .key = key,
+ .value = value,
+ });
+
+ switch (self.token.data) {
+ .comma => try self.scan(),
+ .right_brace => {},
+ .eof => return error.UnterminatedObject,
+ else => return if (self.token.data.canStartValue()) error.ExpectedComma else error.ExpectedClosingBrace,
+ }
+ }
+
+ try self.scan(); // advance past '}'
+ return Expr.init(E.Object, .{
+ .properties = .moveFromList(&properties),
+ }, loc);
+ }
+
+ fn parseObjectKey(self: *JSON5Parser) ParseError!Expr {
+ const loc = self.token.loc;
+ switch (self.token.data) {
+ .string => |s| {
+ try self.scan();
+ return Expr.init(E.String, E.String.init(s), loc);
+ },
+ .identifier => |s| {
+ try self.scan();
+ return Expr.init(E.String, E.String.init(s), loc);
+ },
+ .number => return error.InvalidIdentifier,
+ .boolean => |b| {
+ try self.scan();
+ return Expr.init(E.Boolean, .{ .value = b }, loc);
+ },
+ .null => {
+ try self.scan();
+ return Expr.init(E.Null, .{}, loc);
+ },
+ .eof => return error.UnexpectedEof,
+ else => return error.InvalidIdentifier,
+ }
+ }
+
+ fn parseArray(self: *JSON5Parser) ParseError!Expr {
+ const loc = self.token.loc;
+ try self.scan(); // advance past '['
+
+ var items = std.array_list.Managed(Expr).init(self.allocator);
+
+ while (self.token.data != .right_bracket) {
+ const value = try self.parseValue();
+ try items.append(value);
+
+ switch (self.token.data) {
+ .comma => try self.scan(),
+ .right_bracket => {},
+ .eof => return error.UnterminatedArray,
+ else => return if (self.token.data.canStartValue()) error.ExpectedComma else error.ExpectedClosingBracket,
+ }
+ }
+
+ try self.scan(); // advance past ']'
+ return Expr.init(E.Array, .{
+ .items = .moveFromList(&items),
+ }, loc);
+ }
+
+ // ── Scan Helpers ──
+
+ fn scanString(self: *JSON5Parser) ParseError![]u8 {
+ const quote = self.source[self.pos];
+ self.pos += 1; // skip opening quote
+
+ var buf = std.array_list.Managed(u8).init(self.allocator);
+
+ while (self.pos < self.source.len) {
+ const c = self.source[self.pos];
+
+ if (c == quote) {
+ self.pos += 1;
+ return try buf.toOwnedSlice();
+ }
+
+ if (c == '\\') {
+ self.pos += 1;
+ try self.parseEscapeSequence(&buf);
+ continue;
+ }
+
+ // Line terminators are not allowed unescaped in strings
+ if (c == '\n' or c == '\r') {
+ return error.UnterminatedString;
+ }
+
+ // Check for U+2028/U+2029 (allowed unescaped in JSON5 strings)
+ if (c == 0xE2 and self.pos + 2 < self.source.len and
+ self.source[self.pos + 1] == 0x80 and
+ (self.source[self.pos + 2] == 0xA8 or self.source[self.pos + 2] == 0xA9))
+ {
+ try buf.appendSlice(self.source[self.pos..][0..3]);
+ self.pos += 3;
+ continue;
+ }
+
+ // Regular character - handle multi-byte UTF-8
+ const cp_len = strings.wtf8ByteSequenceLength(c);
+ if (self.pos + cp_len > self.source.len) {
+ try buf.append(c);
+ self.pos += 1;
+ } else {
+ try buf.appendSlice(self.source[self.pos..][0..cp_len]);
+ self.pos += cp_len;
+ }
+ }
+
+ return error.UnterminatedString;
+ }
+
+ fn parseEscapeSequence(self: *JSON5Parser, buf: *std.array_list.Managed(u8)) ParseError!void {
+ if (self.pos >= self.source.len) {
+ return error.UnterminatedEscape;
+ }
+
+ const c = self.source[self.pos];
+ self.pos += 1;
+
+ switch (c) {
+ '\'' => try buf.append('\''),
+ '"' => try buf.append('"'),
+ '\\' => try buf.append('\\'),
+ 'b' => try buf.append(0x08),
+ 'f' => try buf.append(0x0C),
+ 'n' => try buf.append('\n'),
+ 'r' => try buf.append('\r'),
+ 't' => try buf.append('\t'),
+ 'v' => try buf.append(0x0B),
+ '0' => {
+ // \0 null escape - must NOT be followed by a digit
+ if (self.pos < self.source.len) {
+ const next = self.source[self.pos];
+ if (next >= '0' and next <= '9') {
+ return error.OctalEscape;
+ }
+ }
+ try buf.append(0);
+ },
+ 'x' => {
+ // \xHH hex escape
+ const hi = self.readHexDigit() orelse {
+ return error.InvalidHexEscape;
+ };
+ const lo = self.readHexDigit() orelse {
+ return error.InvalidHexEscape;
+ };
+ const value: u8 = (@as(u8, hi) << 4) | lo;
+ try appendCodepointToUtf8(buf, @intCast(value));
+ },
+ 'u' => {
+ // \uHHHH unicode escape
+ const cp = try self.readHex4();
+ // Check for surrogate pair
+ if (cp >= 0xD800 and cp <= 0xDBFF) {
+ // High surrogate - expect \uDCxx low surrogate
+ if (self.pos + 1 < self.source.len and
+ self.source[self.pos] == '\\' and
+ self.source[self.pos + 1] == 'u')
+ {
+ self.pos += 2;
+ const low = try self.readHex4();
+ if (low >= 0xDC00 and low <= 0xDFFF) {
+ const full_cp: i32 = 0x10000 + (cp - 0xD800) * 0x400 + (low - 0xDC00);
+ try appendCodepointToUtf8(buf, full_cp);
+ } else {
+ // Invalid low surrogate - just encode both independently
+ try appendCodepointToUtf8(buf, cp);
+ try appendCodepointToUtf8(buf, low);
+ }
+ } else {
+ try appendCodepointToUtf8(buf, cp);
+ }
+ } else {
+ try appendCodepointToUtf8(buf, cp);
+ }
+ },
+ '\r' => {
+ // Line continuation: \CR or \CRLF
+ if (self.pos < self.source.len and self.source[self.pos] == '\n') {
+ self.pos += 1;
+ }
+ },
+ '\n' => {
+ // Line continuation: \LF
+ },
+ '1'...'9' => {
+ return error.OctalEscape;
+ },
+ 0xE2 => {
+ // Check for U+2028/U+2029 line continuation
+ if (self.pos + 1 < self.source.len and
+ self.source[self.pos] == 0x80 and
+ (self.source[self.pos + 1] == 0xA8 or self.source[self.pos + 1] == 0xA9))
+ {
+ // Line continuation with U+2028 or U+2029
+ self.pos += 2;
+ } else {
+ // Identity escape for the byte 0xE2
+ try buf.append(0xE2);
+ }
+ },
+ else => {
+ // Identity escape
+ try buf.append(c);
+ },
+ }
+ }
+
+ fn scanNumber(self: *JSON5Parser) ParseError!f64 {
+ const start = self.pos;
+
+ // Leading zero: check for hex prefix or invalid leading zeros
+ if (self.peek() == '0' and self.pos + 1 < self.source.len) {
+ switch (self.source[self.pos + 1]) {
+ 'x', 'X' => return self.scanHexNumber(),
+ '0'...'9' => return error.LeadingZeros,
+ else => {},
+ }
+ }
+
+ // Integer part
+ var has_digits = false;
+ while (self.pos < self.source.len) {
+ switch (self.source[self.pos]) {
+ '0'...'9' => {
+ self.pos += 1;
+ has_digits = true;
+ },
+ else => break,
+ }
+ }
+
+ // Fractional part
+ if (self.peek() == '.') {
+ self.pos += 1;
+ var has_frac_digits = false;
+ while (self.pos < self.source.len) {
+ switch (self.source[self.pos]) {
+ '0'...'9' => {
+ self.pos += 1;
+ has_frac_digits = true;
+ },
+ else => break,
+ }
+ }
+ if (!has_digits and !has_frac_digits) {
+ return error.InvalidNumber;
+ }
+ has_digits = true;
+ }
+
+ if (!has_digits) {
+ return error.InvalidNumber;
+ }
+
+ // Exponent part
+ switch (self.peek()) {
+ 'e', 'E' => {
+ self.pos += 1;
+ switch (self.peek()) {
+ '+', '-' => self.pos += 1,
+ else => {},
+ }
+ switch (self.peek()) {
+ '0'...'9' => self.pos += 1,
+ else => return error.InvalidNumber,
+ }
+ while (self.pos < self.source.len) {
+ switch (self.source[self.pos]) {
+ '0'...'9' => self.pos += 1,
+ else => break,
+ }
+ }
+ },
+ else => {},
+ }
+
+ const num_str = self.source[start..self.pos];
+ return std.fmt.parseFloat(f64, num_str) catch {
+ return error.InvalidNumber;
+ };
+ }
+
+ fn scanHexNumber(self: *JSON5Parser) ParseError!f64 {
+ self.pos += 2; // skip '0x' or '0X'
+ const hex_start = self.pos;
+
+ while (self.pos < self.source.len) {
+ switch (self.source[self.pos]) {
+ '0'...'9', 'a'...'f', 'A'...'F' => self.pos += 1,
+ else => break,
+ }
+ }
+
+ if (self.pos == hex_start) {
+ return error.InvalidHexNumber;
+ }
+
+ const hex_str = self.source[hex_start..self.pos];
+ const value = std.fmt.parseInt(u64, hex_str, 16) catch {
+ return error.InvalidHexNumber;
+ };
+ return @floatFromInt(value);
+ }
+
+ fn scanIdentifier(self: *JSON5Parser) ParseError![]u8 {
+ var buf = std.array_list.Managed(u8).init(self.allocator);
+
+ // First character must be IdentifierStart
+ const start_cp = self.readCodepoint() orelse {
+ return error.InvalidIdentifier;
+ };
+
+ if (start_cp.cp == '\\') {
+ // Unicode escape in identifier
+ const escaped_cp = try self.parseIdentifierUnicodeEscape();
+ if (!identifier.isIdentifierStart(escaped_cp)) {
+ return error.InvalidIdentifier;
+ }
+ try appendCodepointToUtf8(&buf, @intCast(escaped_cp));
+ } else if (identifier.isIdentifierStart(start_cp.cp)) {
+ self.pos += start_cp.len;
+ try appendCodepointToUtf8(&buf, @intCast(start_cp.cp));
+ } else {
+ return error.InvalidIdentifier;
+ }
+
+ // Continue characters
+ while (self.pos < self.source.len) {
+ const cont_cp = self.readCodepoint() orelse break;
+
+ if (cont_cp.cp == '\\') {
+ const escaped_cp = try self.parseIdentifierUnicodeEscape();
+ if (!identifier.isIdentifierPart(escaped_cp)) {
+ break;
+ }
+ try appendCodepointToUtf8(&buf, @intCast(escaped_cp));
+ } else if (identifier.isIdentifierPart(cont_cp.cp)) {
+ self.pos += cont_cp.len;
+ try appendCodepointToUtf8(&buf, @intCast(cont_cp.cp));
+ } else {
+ break;
+ }
+ }
+
+ return try buf.toOwnedSlice();
+ }
+
+ fn parseIdentifierUnicodeEscape(self: *JSON5Parser) ParseError!i32 {
+ // We already consumed the '\', now expect 'u' + 4 hex digits
+ self.pos += 1; // skip '\'
+ if (self.pos >= self.source.len or self.source[self.pos] != 'u') {
+ return error.InvalidUnicodeEscape;
+ }
+ self.pos += 1;
+ return self.readHex4();
+ }
+
+ // ── Comment Helpers ──
+
+ fn skipToEndOfLine(self: *JSON5Parser) void {
+ while (self.pos < self.source.len) {
+ const cc = self.source[self.pos];
+ if (cc == '\n' or cc == '\r') break;
+ // Check for U+2028/U+2029 line terminators
+ if (cc == 0xE2 and self.pos + 2 < self.source.len and
+ self.source[self.pos + 1] == 0x80 and
+ (self.source[self.pos + 2] == 0xA8 or self.source[self.pos + 2] == 0xA9))
+ {
+ break;
+ }
+ self.pos += 1;
+ }
+ }
+
+ fn skipBlockComment(self: *JSON5Parser) ParseError!void {
+ while (self.pos < self.source.len) {
+ if (self.source[self.pos] == '*' and self.pos + 1 < self.source.len and self.source[self.pos + 1] == '/') {
+ self.pos += 2;
+ return;
+ }
+ self.pos += 1;
+ }
+ return error.UnterminatedComment;
+ }
+
+ /// Check if the current position has a multi-byte whitespace character.
+ /// Returns the number of bytes consumed, or 0 if not whitespace.
+ fn multiByteWhitespace(self: *const JSON5Parser) u3 {
+ if (self.pos + 1 >= self.source.len) return 0;
+ const b0 = self.source[self.pos];
+ const b1 = self.source[self.pos + 1];
+
+ // U+00A0 NBSP: C2 A0
+ if (b0 == 0xC2 and b1 == 0xA0) return 2;
+
+ if (self.pos + 2 >= self.source.len) return 0;
+ const b2 = self.source[self.pos + 2];
+
+ // U+FEFF BOM: EF BB BF
+ if (b0 == 0xEF and b1 == 0xBB and b2 == 0xBF) return 3;
+
+ // U+2028 LS: E2 80 A8
+ // U+2029 PS: E2 80 A9
+ if (b0 == 0xE2 and b1 == 0x80 and (b2 == 0xA8 or b2 == 0xA9)) return 3;
+
+ // U+1680: E1 9A 80
+ if (b0 == 0xE1 and b1 == 0x9A and b2 == 0x80) return 3;
+
+ // U+2000-U+200A: E2 80 80-8A
+ if (b0 == 0xE2 and b1 == 0x80 and b2 >= 0x80 and b2 <= 0x8A) return 3;
+
+ // U+202F: E2 80 AF
+ if (b0 == 0xE2 and b1 == 0x80 and b2 == 0xAF) return 3;
+
+ // U+205F: E2 81 9F
+ if (b0 == 0xE2 and b1 == 0x81 and b2 == 0x9F) return 3;
+
+ // U+3000: E3 80 80
+ if (b0 == 0xE3 and b1 == 0x80 and b2 == 0x80) return 3;
+
+ return 0;
+ }
+
+ // ── Helper Functions ──
+
+ fn readHexDigit(self: *JSON5Parser) ?u4 {
+ if (self.pos >= self.source.len) return null;
+ const c = self.source[self.pos];
+ const result: u4 = switch (c) {
+ '0'...'9' => @intCast(c - '0'),
+ 'a'...'f' => @intCast(c - 'a' + 10),
+ 'A'...'F' => @intCast(c - 'A' + 10),
+ else => return null,
+ };
+ self.pos += 1;
+ return result;
+ }
+
+ fn readHex4(self: *JSON5Parser) ParseError!i32 {
+ var value: i32 = 0;
+ comptime var i: usize = 0;
+ inline while (i < 4) : (i += 1) {
+ const digit = self.readHexDigit() orelse {
+ return error.InvalidUnicodeEscape;
+ };
+ value = (value << 4) | @as(i32, digit);
+ }
+ return value;
+ }
+
+ const Codepoint = struct {
+ cp: i32,
+ len: u3,
+ };
+
+ fn readCodepoint(self: *const JSON5Parser) ?Codepoint {
+ if (self.pos >= self.source.len) return null;
+ const first = self.source[self.pos];
+ if (first < 0x80) {
+ return .{ .cp = @intCast(first), .len = 1 };
+ }
+ const seq_len = strings.wtf8ByteSequenceLength(first);
+ if (self.pos + seq_len > self.source.len) {
+ return .{ .cp = @intCast(first), .len = 1 };
+ }
+ const decoded = strings.decodeWTF8RuneT(self.source[self.pos..].ptr[0..4], seq_len, i32, -1);
+ if (decoded < 0) return .{ .cp = @intCast(first), .len = 1 };
+ return .{ .cp = decoded, .len = @intCast(seq_len) };
+ }
+
+ fn appendCodepointToUtf8(buf: *std.array_list.Managed(u8), cp: i32) ParseError!void {
+ if (cp < 0 or cp > 0x10FFFF) {
+ return error.InvalidUnicodeEscape;
+ }
+ var encoded: [4]u8 = undefined;
+ const len = strings.encodeWTF8Rune(&encoded, cp);
+ try buf.appendSlice(encoded[0..len]);
+ }
+
+ fn isIdentContinueASCII(c: u8) bool {
+ return switch (c) {
+ 'a'...'z', 'A'...'Z', '0'...'9', '_', '$' => true,
+ else => false,
+ };
+ }
+};
+
+const identifier = @import("../js_lexer/identifier.zig");
+const std = @import("std");
+
+const bun = @import("bun");
+const OOM = bun.OOM;
+const logger = bun.logger;
+const strings = bun.strings;
+
+const E = bun.ast.E;
+const Expr = bun.ast.Expr;
+const G = bun.ast.G;
diff --git a/src/interchange/yaml.zig b/src/interchange/yaml.zig
index 9f38ec1050..38b5f230f0 100644
--- a/src/interchange/yaml.zig
+++ b/src/interchange/yaml.zig
@@ -401,10 +401,10 @@ pub fn Parser(comptime enc: Encoding) type {
pos: Pos,
},
- pub fn addToLog(this: *const Error, source: *const logger.Source, log: *logger.Log) OOM!void {
+ pub fn addToLog(this: *const Error, source: *const logger.Source, log: *logger.Log) (OOM || error{StackOverflow})!void {
switch (this.*) {
.oom => return error.OutOfMemory,
- .stack_overflow => {},
+ .stack_overflow => return error.StackOverflow,
.unexpected_eof => |e| {
try log.addError(source, e.pos.loc(), "Unexpected EOF");
},
diff --git a/src/js_printer.zig b/src/js_printer.zig
index 0431180838..9d6f48f8c1 100644
--- a/src/js_printer.zig
+++ b/src/js_printer.zig
@@ -4487,6 +4487,7 @@ fn NewPrinter(
.jsonc => p.printWhitespacer(ws(" with { type: \"jsonc\" }")),
.toml => p.printWhitespacer(ws(" with { type: \"toml\" }")),
.yaml => p.printWhitespacer(ws(" with { type: \"yaml\" }")),
+ .json5 => p.printWhitespacer(ws(" with { type: \"json5\" }")),
.wasm => p.printWhitespacer(ws(" with { type: \"wasm\" }")),
.napi => p.printWhitespacer(ws(" with { type: \"napi\" }")),
.base64 => p.printWhitespacer(ws(" with { type: \"base64\" }")),
diff --git a/src/options.zig b/src/options.zig
index 27fd53b017..c770a92dfa 100644
--- a/src/options.zig
+++ b/src/options.zig
@@ -634,6 +634,7 @@ pub const Loader = enum(u8) {
sqlite_embedded = 16,
html = 17,
yaml = 18,
+ json5 = 19,
pub const Optional = enum(u8) {
none = 254,
@@ -702,7 +703,7 @@ pub const Loader = enum(u8) {
return switch (this) {
.jsx, .js, .ts, .tsx => bun.http.MimeType.javascript,
.css => bun.http.MimeType.css,
- .toml, .yaml, .json, .jsonc => bun.http.MimeType.json,
+ .toml, .yaml, .json, .jsonc, .json5 => bun.http.MimeType.json,
.wasm => bun.http.MimeType.wasm,
.html => bun.http.MimeType.html,
else => {
@@ -751,6 +752,7 @@ pub const Loader = enum(u8) {
map.set(.json, "input.json");
map.set(.toml, "input.toml");
map.set(.yaml, "input.yaml");
+ map.set(.json5, "input.json5");
map.set(.wasm, "input.wasm");
map.set(.napi, "input.node");
map.set(.text, "input.txt");
@@ -794,6 +796,7 @@ pub const Loader = enum(u8) {
.{ "jsonc", .jsonc },
.{ "toml", .toml },
.{ "yaml", .yaml },
+ .{ "json5", .json5 },
.{ "wasm", .wasm },
.{ "napi", .napi },
.{ "node", .napi },
@@ -822,6 +825,7 @@ pub const Loader = enum(u8) {
.{ "jsonc", .json },
.{ "toml", .toml },
.{ "yaml", .yaml },
+ .{ "json5", .json5 },
.{ "wasm", .wasm },
.{ "node", .napi },
.{ "dataurl", .dataurl },
@@ -862,6 +866,7 @@ pub const Loader = enum(u8) {
.jsonc => .json,
.toml => .toml,
.yaml => .yaml,
+ .json5 => .json5,
.wasm => .wasm,
.napi => .napi,
.base64 => .base64,
@@ -884,6 +889,7 @@ pub const Loader = enum(u8) {
.jsonc => .jsonc,
.toml => .toml,
.yaml => .yaml,
+ .json5 => .json5,
.wasm => .wasm,
.napi => .napi,
.base64 => .base64,
@@ -916,8 +922,8 @@ pub const Loader = enum(u8) {
return switch (loader) {
.jsx, .js, .ts, .tsx, .json, .jsonc => true,
- // toml and yaml are included because we can serialize to the same AST as JSON
- .toml, .yaml => true,
+ // toml, yaml, and json5 are included because we can serialize to the same AST as JSON
+ .toml, .yaml, .json5 => true,
else => false,
};
@@ -932,7 +938,7 @@ pub const Loader = enum(u8) {
pub fn sideEffects(this: Loader) bun.resolver.SideEffects {
return switch (this) {
- .text, .json, .jsonc, .toml, .yaml, .file => bun.resolver.SideEffects.no_side_effects__pure_data,
+ .text, .json, .jsonc, .toml, .yaml, .json5, .file => bun.resolver.SideEffects.no_side_effects__pure_data,
else => bun.resolver.SideEffects.has_side_effects,
};
}
@@ -947,7 +953,7 @@ pub const Loader = enum(u8) {
} else if (strings.hasPrefixComptime(mime_type.value, "application/typescript")) {
return .ts;
} else if (strings.hasPrefixComptime(mime_type.value, "application/json5")) {
- return .jsonc;
+ return .json5;
} else if (strings.hasPrefixComptime(mime_type.value, "application/jsonc")) {
return .jsonc;
} else if (strings.hasPrefixComptime(mime_type.value, "application/json")) {
@@ -1111,6 +1117,7 @@ const default_loaders_posix = .{
.{ ".text", .text },
.{ ".html", .html },
.{ ".jsonc", .jsonc },
+ .{ ".json5", .json5 },
};
const default_loaders_win32 = default_loaders_posix ++ .{
.{ ".sh", .bunsh },
@@ -1548,7 +1555,7 @@ const default_loader_ext = [_]string{
".yml", ".wasm",
".txt", ".text",
- ".jsonc",
+ ".jsonc", ".json5",
};
// Only set it for browsers by default.
@@ -1569,6 +1576,7 @@ const node_modules_default_loader_ext = [_]string{
".txt",
".json",
".jsonc",
+ ".json5",
".css",
".tsx",
".cts",
diff --git a/src/transpiler.zig b/src/transpiler.zig
index 3ea5becc15..3315f61245 100644
--- a/src/transpiler.zig
+++ b/src/transpiler.zig
@@ -626,7 +626,7 @@ pub const Transpiler = struct {
};
switch (loader) {
- .jsx, .tsx, .js, .ts, .json, .jsonc, .toml, .yaml, .text => {
+ .jsx, .tsx, .js, .ts, .json, .jsonc, .toml, .yaml, .json5, .text => {
var result = transpiler.parse(
ParseOptions{
.allocator = transpiler.allocator,
@@ -1189,7 +1189,7 @@ pub const Transpiler = struct {
};
},
// TODO: use lazy export AST
- inline .toml, .yaml, .json, .jsonc => |kind| {
+ inline .toml, .yaml, .json, .jsonc, .json5 => |kind| {
var expr = if (kind == .jsonc)
// We allow importing tsconfig.*.json or jsconfig.*.json with comments
// These files implicitly become JSONC files, which aligns with the behavior of text editors.
@@ -1200,6 +1200,8 @@ pub const Transpiler = struct {
TOML.parse(source, transpiler.log, allocator, false) catch return null
else if (kind == .yaml)
YAML.parse(source, transpiler.log, allocator) catch return null
+ else if (kind == .json5)
+ JSON5.parse(source, transpiler.log, allocator) catch return null
else
@compileError("unreachable");
@@ -1616,6 +1618,7 @@ const jsc = bun.jsc;
const logger = bun.logger;
const strings = bun.strings;
const api = bun.schema.api;
+const JSON5 = bun.interchange.json5.JSON5Parser;
const TOML = bun.interchange.toml.TOML;
const YAML = bun.interchange.yaml.YAML;
const default_macro_js_value = jsc.JSValue.zero;
diff --git a/test/js/bun/json5/generate_json5_test_suite.ts b/test/js/bun/json5/generate_json5_test_suite.ts
new file mode 100644
index 0000000000..9471d6ebea
--- /dev/null
+++ b/test/js/bun/json5/generate_json5_test_suite.ts
@@ -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: "
+ 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);
+ 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}`);
diff --git a/test/js/bun/json5/json5-test-suite.test.ts b/test/js/bun/json5/json5-test-suite.test.ts
new file mode 100644
index 0000000000..996972cb55
--- /dev/null
+++ b/test/js/bun/json5/json5-test-suite.test.ts
@@ -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 (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 (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 (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 (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);
+ });
+});
diff --git a/test/js/bun/json5/json5.test.ts b/test/js/bun/json5/json5.test.ts
new file mode 100644
index 0000000000..f04707e1a4
--- /dev/null
+++ b/test/js/bun/json5/json5.test.ts
@@ -0,0 +1,1573 @@
+// 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]);
+ });
+ });
+});
diff --git a/test/js/bun/resolve/json5/json5-empty.json5 b/test/js/bun/resolve/json5/json5-empty.json5
new file mode 100644
index 0000000000..25634f07b1
--- /dev/null
+++ b/test/js/bun/resolve/json5/json5-empty.json5
@@ -0,0 +1,2 @@
+// A JSON5 file with just null
+null
diff --git a/test/js/bun/resolve/json5/json5-fixture.json5 b/test/js/bun/resolve/json5/json5-fixture.json5
new file mode 100644
index 0000000000..bcf9e7a14f
--- /dev/null
+++ b/test/js/bun/resolve/json5/json5-fixture.json5
@@ -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",
+ },
+}
diff --git a/test/js/bun/resolve/json5/json5-fixture.json5.txt b/test/js/bun/resolve/json5/json5-fixture.json5.txt
new file mode 100644
index 0000000000..cfdc41eede
--- /dev/null
+++ b/test/js/bun/resolve/json5/json5-fixture.json5.txt
@@ -0,0 +1,8 @@
+{
+ framework: "next",
+ bundle: {
+ packages: {
+ "@emotion/react": true,
+ },
+ },
+}
diff --git a/test/js/bun/resolve/json5/json5.test.js b/test/js/bun/resolve/json5/json5.test.js
new file mode 100644
index 0000000000..3c64e4f979
--- /dev/null
+++ b/test/js/bun/resolve/json5/json5.test.js
@@ -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);
+});
diff --git a/test/js/bun/yaml/yaml.test.ts b/test/js/bun/yaml/yaml.test.ts
index 29813c003d..e149fcadbc 100644
--- a/test/js/bun/yaml/yaml.test.ts
+++ b/test/js/bun/yaml/yaml.test.ts
@@ -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");