From b59c77a6e7f4af47e769e8dc117c39e2e0afa8b2 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Mon, 26 Jan 2026 10:52:35 -0800 Subject: [PATCH] feat: add native JSON5 parser (Bun.JSON5) (#26439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `Bun.JSON5.parse()` and `Bun.JSON5.stringify()` as built-in APIs - Adds `.json5` file support in the module resolver and bundler - Parser uses a scanner/parser split architecture with a labeled switch pattern (like the YAML parser) — the scanner produces typed tokens, the parser never touches source bytes directly - 430+ tests covering the official JSON5 test suite, escape sequences, numbers, comments, whitespace (including all Unicode whitespace types), unquoted/reserved-word keys, unicode identifiers, deeply nested structures, garbage input, error messages, and stringify behavior Screenshot 2026-01-25 at 12 19 57 AM ## Test plan - [x] `bun bd test test/js/bun/json5/json5.test.ts` — 317 tests - [x] `bun bd test test/js/bun/json5/json5-test-suite.test.ts` — 113 tests from the official JSON5 test suite - [x] `bun bd test test/js/bun/resolve/json5/json5.test.js` — .json5 module resolution closes #3175 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- bench/json5/bun.lock | 15 + bench/json5/json5.mjs | 88 + bench/json5/package.json | 7 + docs/docs.json | 2 + docs/guides/runtime/import-json5.mdx | 74 + docs/runtime/file-types.mdx | 49 +- docs/runtime/json5.mdx | 271 +++ packages/bun-types/bun.d.ts | 63 + packages/bun-types/extensions.d.ts | 5 + src/api/schema.zig | 1 + src/bake/DevServer/DirectoryWatchStore.zig | 1 + src/bun.js/ModuleLoader.zig | 6 +- src/bun.js/api.zig | 1 + src/bun.js/api/BunObject.zig | 7 + src/bun.js/api/JSON5Object.zig | 433 +++++ src/bun.js/api/YAMLObject.zig | 21 +- src/bun.js/bindings/BunObject+exports.h | 1 + src/bun.js/bindings/BunObject.cpp | 1 + .../bindings/generated_perf_trace_events.h | 115 +- src/bundler/LinkerContext.zig | 2 +- src/bundler/ParseTask.zig | 12 + src/generated_perf_trace_events.zig | 23 +- src/interchange.zig | 1 + src/interchange/json5.zig | 913 ++++++++++ src/interchange/yaml.zig | 4 +- src/js_printer.zig | 1 + src/options.zig | 20 +- src/transpiler.zig | 7 +- .../js/bun/json5/generate_json5_test_suite.ts | 241 +++ test/js/bun/json5/json5-test-suite.test.ts | 934 ++++++++++ test/js/bun/json5/json5.test.ts | 1573 +++++++++++++++++ test/js/bun/resolve/json5/json5-empty.json5 | 2 + test/js/bun/resolve/json5/json5-fixture.json5 | 32 + .../bun/resolve/json5/json5-fixture.json5.txt | 8 + test/js/bun/resolve/json5/json5.test.js | 63 + test/js/bun/yaml/yaml.test.ts | 27 + 36 files changed, 4932 insertions(+), 92 deletions(-) create mode 100644 bench/json5/bun.lock create mode 100644 bench/json5/json5.mjs create mode 100644 bench/json5/package.json create mode 100644 docs/guides/runtime/import-json5.mdx create mode 100644 docs/runtime/json5.mdx create mode 100644 src/bun.js/api/JSON5Object.zig create mode 100644 src/interchange/json5.zig create mode 100644 test/js/bun/json5/generate_json5_test_suite.ts create mode 100644 test/js/bun/json5/json5-test-suite.test.ts create mode 100644 test/js/bun/json5/json5.test.ts create mode 100644 test/js/bun/resolve/json5/json5-empty.json5 create mode 100644 test/js/bun/resolve/json5/json5-fixture.json5 create mode 100644 test/js/bun/resolve/json5/json5-fixture.json5.txt create mode 100644 test/js/bun/resolve/json5/json5.test.js 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");