diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index afa5f9d555..6ab778c97f 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -625,6 +625,33 @@ declare module "bun" { export function parse(input: string): object; } + /** + * JSONC related APIs + */ + namespace JSONC { + /** + * Parse a JSONC (JSON with Comments) string into a JavaScript value. + * + * Supports both single-line (`//`) and block comments (`/* ... *\/`), as well as + * trailing commas in objects and arrays. + * + * @category Utilities + * + * @param input The JSONC string to parse + * @returns A JavaScript value + * + * @example + * ```js + * const result = Bun.JSONC.parse(`{ + * // This is a comment + * "name": "my-app", + * "version": "1.0.0", // trailing comma is allowed + * }`); + * ``` + */ + export function parse(input: string): unknown; + } + /** * YAML related APIs */ diff --git a/src/bun.js/api.zig b/src/bun.js/api.zig index 1d5f106b88..1b54ea0daf 100644 --- a/src/bun.js/api.zig +++ b/src/bun.js/api.zig @@ -26,8 +26,9 @@ pub const SocketHandlers = @import("./api/bun/socket.zig").Handlers; pub const Subprocess = @import("./api/bun/subprocess.zig"); pub const Terminal = @import("./api/bun/Terminal.zig"); pub const HashObject = @import("./api/HashObject.zig"); -pub const UnsafeObject = @import("./api/UnsafeObject.zig"); +pub const JSONCObject = @import("./api/JSONCObject.zig"); pub const TOMLObject = @import("./api/TOMLObject.zig"); +pub const UnsafeObject = @import("./api/UnsafeObject.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 831c4c5008..53322882a2 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -62,6 +62,7 @@ pub const BunObject = struct { pub const SHA384 = toJSLazyPropertyCallback(Crypto.SHA384.getter); pub const SHA512 = toJSLazyPropertyCallback(Crypto.SHA512.getter); pub const SHA512_256 = toJSLazyPropertyCallback(Crypto.SHA512_256.getter); + pub const JSONC = toJSLazyPropertyCallback(Bun.getJSONCObject); pub const TOML = toJSLazyPropertyCallback(Bun.getTOMLObject); pub const YAML = toJSLazyPropertyCallback(Bun.getYAMLObject); pub const Transpiler = toJSLazyPropertyCallback(Bun.getTranspilerConstructor); @@ -127,7 +128,7 @@ pub const BunObject = struct { @export(&BunObject.SHA384, .{ .name = lazyPropertyCallbackName("SHA384") }); @export(&BunObject.SHA512, .{ .name = lazyPropertyCallbackName("SHA512") }); @export(&BunObject.SHA512_256, .{ .name = lazyPropertyCallbackName("SHA512_256") }); - + @export(&BunObject.JSONC, .{ .name = lazyPropertyCallbackName("JSONC") }); @export(&BunObject.TOML, .{ .name = lazyPropertyCallbackName("TOML") }); @export(&BunObject.YAML, .{ .name = lazyPropertyCallbackName("YAML") }); @export(&BunObject.Glob, .{ .name = lazyPropertyCallbackName("Glob") }); @@ -1261,6 +1262,9 @@ pub fn getHashObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSVa return HashObject.create(globalThis); } +pub fn getJSONCObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return JSONCObject.create(globalThis); +} pub fn getTOMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { return TOMLObject.create(globalThis); } @@ -2058,6 +2062,7 @@ const gen = bun.gen.BunObject; const api = bun.api; const FFIObject = bun.api.FFIObject; const HashObject = bun.api.HashObject; +const JSONCObject = bun.api.JSONCObject; const TOMLObject = bun.api.TOMLObject; const UnsafeObject = bun.api.UnsafeObject; const YAMLObject = bun.api.YAMLObject; diff --git a/src/bun.js/api/JSONCObject.zig b/src/bun.js/api/JSONCObject.zig new file mode 100644 index 0000000000..f676404ebc --- /dev/null +++ b/src/bun.js/api/JSONCObject.zig @@ -0,0 +1,55 @@ +pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue { + const object = JSValue.createEmptyObject(globalThis, 1); + object.put( + globalThis, + ZigString.static("parse"), + jsc.JSFunction.create( + globalThis, + "parse", + parse, + 1, + .{}, + ), + ); + + return object; +} + +pub fn parse( + globalThis: *jsc.JSGlobalObject, + callframe: *jsc.CallFrame, +) bun.JSError!jsc.JSValue { + var arena = bun.ArenaAllocator.init(globalThis.allocator()); + const allocator = arena.allocator(); + defer arena.deinit(); + var log = logger.Log.init(default_allocator); + defer log.deinit(); + const input_value = callframe.argument(0); + if (input_value.isEmptyOrUndefinedOrNull()) { + return globalThis.throwInvalidArguments("Expected a string to parse", .{}); + } + + var input_slice = try input_value.toSlice(globalThis, bun.default_allocator); + defer input_slice.deinit(); + const source = &logger.Source.initPathString("input.jsonc", input_slice.slice()); + const parse_result = json.parseTSConfig(source, &log, allocator, true) catch { + return globalThis.throwValue(try log.toJS(globalThis, default_allocator, "Failed to parse JSONC")); + }; + + return parse_result.toJS(allocator, globalThis) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.JSError => return error.JSError, + error.JSTerminated => return error.JSTerminated, + // JSONC parsing does not produce macros or identifiers + else => unreachable, + }; +} + +const bun = @import("bun"); +const default_allocator = bun.default_allocator; +const logger = bun.logger; +const json = bun.interchange.json; + +const jsc = bun.jsc; +const JSValue = jsc.JSValue; +const ZigString = jsc.ZigString; diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index b4481f7e47..d2fc9d55ed 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -8,6 +8,7 @@ macro(FFI) \ macro(FileSystemRouter) \ macro(Glob) \ + macro(JSONC) \ macro(MD4) \ macro(MD5) \ macro(S3Client) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index c94784cf96..5400dc906a 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -727,6 +727,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj SHA384 BunObject_lazyPropCb_wrap_SHA384 DontDelete|PropertyCallback SHA512 BunObject_lazyPropCb_wrap_SHA512 DontDelete|PropertyCallback SHA512_256 BunObject_lazyPropCb_wrap_SHA512_256 DontDelete|PropertyCallback + JSONC BunObject_lazyPropCb_wrap_JSONC DontDelete|PropertyCallback TOML BunObject_lazyPropCb_wrap_TOML DontDelete|PropertyCallback YAML BunObject_lazyPropCb_wrap_YAML DontDelete|PropertyCallback Transpiler BunObject_lazyPropCb_wrap_Transpiler DontDelete|PropertyCallback diff --git a/test/js/bun/jsonc/jsonc.test.ts b/test/js/bun/jsonc/jsonc.test.ts new file mode 100644 index 0000000000..b3b2478e82 --- /dev/null +++ b/test/js/bun/jsonc/jsonc.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; + +test("Bun.JSONC exists", () => { + expect(Bun.JSONC).toBeDefined(); + expect(typeof Bun.JSONC).toBe("object"); + expect(typeof Bun.JSONC.parse).toBe("function"); +}); + +test("Bun.JSONC.parse handles basic JSON", () => { + const result = Bun.JSONC.parse('{"name": "test", "value": 42}'); + expect(result).toEqual({ name: "test", value: 42 }); +}); + +test("Bun.JSONC.parse handles comments", () => { + const jsonc = `{ + // This is a comment + "name": "test", + /* This is a block comment */ + "value": 42 + }`; + + const result = Bun.JSONC.parse(jsonc); + expect(result).toEqual({ name: "test", value: 42 }); +}); + +test("Bun.JSONC.parse handles trailing commas", () => { + const jsonc = `{ + "name": "test", + "value": 42, + }`; + + const result = Bun.JSONC.parse(jsonc); + expect(result).toEqual({ name: "test", value: 42 }); +}); + +test("Bun.JSONC.parse handles arrays with trailing commas", () => { + const jsonc = `[ + 1, + 2, + 3, + ]`; + + const result = Bun.JSONC.parse(jsonc); + expect(result).toEqual([1, 2, 3]); +}); + +test("Bun.JSONC.parse handles complex JSONC", () => { + const jsonc = `{ + // Configuration object + "name": "my-app", + "version": "1.0.0", + /* Dependencies section */ + "dependencies": { + "react": "^18.0.0", + "typescript": "^5.0.0", // Latest TypeScript + }, + "scripts": [ + "build", + "test", + "lint", // Code formatting + ], + }`; + + const result = Bun.JSONC.parse(jsonc); + expect(result).toEqual({ + name: "my-app", + version: "1.0.0", + dependencies: { + react: "^18.0.0", + typescript: "^5.0.0", + }, + scripts: ["build", "test", "lint"], + }); +}); + +test("Bun.JSONC.parse handles nested objects", () => { + const jsonc = `{ + "outer": { + // Nested comment + "inner": { + "value": 123, + } + }, + }`; + + const result = Bun.JSONC.parse(jsonc); + expect(result).toEqual({ + outer: { + inner: { + value: 123, + }, + }, + }); +}); + +test("Bun.JSONC.parse handles boolean and null values", () => { + const jsonc = `{ + "enabled": true, // Boolean true + "disabled": false, // Boolean false + "nothing": null, // Null value + }`; + + const result = Bun.JSONC.parse(jsonc); + expect(result).toEqual({ + enabled: true, + disabled: false, + nothing: null, + }); +}); + +test("Bun.JSONC.parse throws on invalid JSON", () => { + expect(() => { + Bun.JSONC.parse("{ invalid json }"); + }).toThrow(); +}); + +test("Bun.JSONC.parse handles empty object", () => { + const result = Bun.JSONC.parse("{}"); + expect(result).toEqual({}); +}); + +test("Bun.JSONC.parse handles empty array", () => { + const result = Bun.JSONC.parse("[]"); + expect(result).toEqual([]); +});