From fcaff77ed7fb29c06d351f450e0d3f2af8ca16ef Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Mon, 1 Sep 2025 01:27:51 +0000 Subject: [PATCH] Implement `Bun.YAML.stringify` (#22183) ### What does this PR do? This PR adds `Bun.YAML.stringify`. The stringifier will double quote strings only when necessary (looks for keywords, numbers, or containing non-printable or escaped characters). Anchors and aliases are detected by object equality, and anchor name is chosen from property name, array item, or the root collection. ```js import { YAML } from "bun" YAML.stringify(null) // null YAML.stringify("hello YAML"); // "hello YAML" YAML.stringify("123.456"); // "\"123.456\"" // anchors and aliases const userInfo = { name: "bun" }; const obj = { user1: { userInfo }, user2: { userInfo } }; YAML.stringify(obj, null, 2); // # output // user1: // userInfo: // &userInfo // name: bun // user2: // userInfo: // *userInfo // will handle cycles const obj = {}; obj.cycle = obj; YAML.stringify(obj, null, 2); // # output // &root // cycle: // *root // default no space const obj = { one: { two: "three" } }; YAML.stringify(obj); // # output // {one: {two: three}} ``` ### How did you verify your code works? Added tests for basic use and edgecases ## Summary by CodeRabbit - New Features - Added YAML.stringify to the YAML API, producing YAML from JavaScript values with quoting, anchors, and indentation support. - Improvements - YAML.parse now accepts a wider range of inputs, including Buffer, ArrayBuffer, TypedArrays, DataView, Blob/File, and SharedArrayBuffer, with better error propagation and stack protection. - Tests - Extensive new tests for YAML.parse and YAML.stringify across data types, edge cases, anchors/aliases, deep nesting, and round-trip scenarios. - Chores - Added a YAML stringify benchmark script covering multiple libraries and data shapes. --------- Co-authored-by: Claude Bot Co-authored-by: Claude Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- bench/yaml/yaml-stringify.mjs | 407 ++++ cmake/sources/CxxSources.txt | 1 + cmake/sources/ZigSources.txt | 1 + packages/bun-types/bun.d.ts | 32 + src/bun.js/api/YAMLObject.zig | 909 +++++++- src/bun.js/bindings/BunString.cpp | 15 + src/bun.js/bindings/CatchScope.zig | 2 +- src/bun.js/bindings/CatchScopeBinding.cpp | 11 + src/bun.js/bindings/JSPropertyIterator.cpp | 22 +- src/bun.js/bindings/JSValue.zig | 11 + src/bun.js/bindings/StringBuilder.zig | 91 + src/bun.js/bindings/StringBuilderBinding.cpp | 81 + src/bun.js/bindings/WTF.zig | 2 + src/bun.js/bindings/bindings.cpp | 23 + src/bun.js/bindings/headers-handwritten.h | 2 + src/bun.js/bindings/helpers.h | 18 + src/bun.zig | 2 +- src/string.zig | 19 +- test/integration/bun-types/fixture/yaml.ts | 5 + test/js/bun/yaml/yaml.test.ts | 1996 +++++++++++++++++- 20 files changed, 3569 insertions(+), 81 deletions(-) create mode 100644 bench/yaml/yaml-stringify.mjs create mode 100644 src/bun.js/bindings/StringBuilder.zig create mode 100644 src/bun.js/bindings/StringBuilderBinding.cpp diff --git a/bench/yaml/yaml-stringify.mjs b/bench/yaml/yaml-stringify.mjs new file mode 100644 index 0000000000..9014191e54 --- /dev/null +++ b/bench/yaml/yaml-stringify.mjs @@ -0,0 +1,407 @@ +import { bench, group, run } from "../runner.mjs"; +import jsYaml from "js-yaml"; +import yaml from "yaml"; + +// Small object +const smallObject = { + name: "John Doe", + age: 30, + email: "john@example.com", + active: true, +}; + +// Medium object with nested structures +const mediumObject = { + company: "Acme Corp", + employees: [ + { + name: "John Doe", + age: 30, + position: "Developer", + skills: ["JavaScript", "TypeScript", "Node.js"], + }, + { + name: "Jane Smith", + age: 28, + position: "Designer", + skills: ["Figma", "Photoshop", "Illustrator"], + }, + { + name: "Bob Johnson", + age: 35, + position: "Manager", + skills: ["Leadership", "Communication", "Planning"], + }, + ], + settings: { + database: { + host: "localhost", + port: 5432, + name: "mydb", + }, + cache: { + enabled: true, + ttl: 3600, + }, + }, +}; + +// Large object with complex structures +const largeObject = { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "nginx-deployment", + labels: { + app: "nginx", + }, + }, + spec: { + replicas: 3, + selector: { + matchLabels: { + app: "nginx", + }, + }, + template: { + metadata: { + labels: { + app: "nginx", + }, + }, + spec: { + containers: [ + { + name: "nginx", + image: "nginx:1.14.2", + ports: [ + { + containerPort: 80, + }, + ], + env: [ + { + name: "ENV_VAR_1", + value: "value1", + }, + { + name: "ENV_VAR_2", + value: "value2", + }, + ], + volumeMounts: [ + { + name: "config", + mountPath: "/etc/nginx", + }, + ], + resources: { + limits: { + cpu: "1", + memory: "1Gi", + }, + requests: { + cpu: "0.5", + memory: "512Mi", + }, + }, + }, + ], + volumes: [ + { + name: "config", + configMap: { + name: "nginx-config", + items: [ + { + key: "nginx.conf", + path: "nginx.conf", + }, + { + key: "mime.types", + path: "mime.types", + }, + ], + }, + }, + ], + nodeSelector: { + disktype: "ssd", + }, + tolerations: [ + { + key: "key1", + operator: "Equal", + value: "value1", + effect: "NoSchedule", + }, + { + key: "key2", + operator: "Exists", + effect: "NoExecute", + }, + ], + affinity: { + nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: "kubernetes.io/e2e-az-name", + operator: "In", + values: ["e2e-az1", "e2e-az2"], + }, + ], + }, + ], + }, + }, + podAntiAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 100, + podAffinityTerm: { + labelSelector: { + matchExpressions: [ + { + key: "app", + operator: "In", + values: ["web-store"], + }, + ], + }, + topologyKey: "kubernetes.io/hostname", + }, + }, + ], + }, + }, + }, + }, + }, +}; + +// Object with anchors and references (after resolution) +const objectWithAnchors = { + defaults: { + adapter: "postgresql", + host: "localhost", + port: 5432, + }, + development: { + adapter: "postgresql", + host: "localhost", + port: 5432, + database: "dev_db", + }, + test: { + adapter: "postgresql", + host: "localhost", + port: 5432, + database: "test_db", + }, + production: { + adapter: "postgresql", + host: "prod.example.com", + port: 5432, + database: "prod_db", + }, +}; + +// Array of items +const arrayObject = [ + { + id: 1, + name: "Item 1", + price: 10.99, + tags: ["electronics", "gadgets"], + }, + { + id: 2, + name: "Item 2", + price: 25.5, + tags: ["books", "education"], + }, + { + id: 3, + name: "Item 3", + price: 5.0, + tags: ["food", "snacks"], + }, + { + id: 4, + name: "Item 4", + price: 100.0, + tags: ["electronics", "computers"], + }, + { + id: 5, + name: "Item 5", + price: 15.75, + tags: ["clothing", "accessories"], + }, +]; + +// Multiline strings +const multilineObject = { + description: + "This is a multiline string\nthat preserves line breaks\nand indentation.\n\nIt can contain multiple paragraphs\nand special characters: !@#$%^&*()\n", + folded: "This is a folded string where line breaks are converted to spaces unless there are\nempty lines like above.", + plain: "This is a plain string", + quoted: 'This is a quoted string with "escapes"', + literal: "This is a literal string with 'quotes'", +}; + +// Numbers and special values +const numbersObject = { + integer: 42, + negative: -17, + float: 3.14159, + scientific: 0.000123, + infinity: Infinity, + negativeInfinity: -Infinity, + notANumber: NaN, + octal: 493, // 0o755 + hex: 255, // 0xFF + binary: 10, // 0b1010 +}; + +// Dates and timestamps +const datesObject = { + date: new Date("2024-01-15"), + datetime: new Date("2024-01-15T10:30:00Z"), + timestamp: new Date("2024-01-15T15:30:00.123456789Z"), // Adjusted for UTC-5 + canonical: new Date("2024-01-15T10:30:00.123456789Z"), +}; + +// Stringify benchmarks +group("stringify small object", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(smallObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(smallObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(smallObject); + }); +}); + +group("stringify medium object", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(mediumObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(mediumObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(mediumObject); + }); +}); + +group("stringify large object", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(largeObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(largeObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(largeObject); + }); +}); + +group("stringify object with anchors", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(objectWithAnchors); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(objectWithAnchors); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(objectWithAnchors); + }); +}); + +group("stringify array", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(arrayObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(arrayObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(arrayObject); + }); +}); + +group("stringify object with multiline strings", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(multilineObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(multilineObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(multilineObject); + }); +}); + +group("stringify object with numbers", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(numbersObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(numbersObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(numbersObject); + }); +}); + +group("stringify object with dates", () => { + if (typeof Bun !== "undefined" && Bun.YAML) { + bench("Bun.YAML.stringify", () => { + return Bun.YAML.stringify(datesObject); + }); + } + + bench("js-yaml.dump", () => { + return jsYaml.dump(datesObject); + }); + + bench("yaml.stringify", () => { + return yaml.stringify(datesObject); + }); +}); + +await run(); diff --git a/cmake/sources/CxxSources.txt b/cmake/sources/CxxSources.txt index dd42fd66b9..03ba693fb5 100644 --- a/cmake/sources/CxxSources.txt +++ b/cmake/sources/CxxSources.txt @@ -198,6 +198,7 @@ src/bun.js/bindings/ServerRouteList.cpp src/bun.js/bindings/spawn.cpp src/bun.js/bindings/SQLClient.cpp src/bun.js/bindings/sqlite/JSSQLStatement.cpp +src/bun.js/bindings/StringBuilderBinding.cpp src/bun.js/bindings/stripANSI.cpp src/bun.js/bindings/Strong.cpp src/bun.js/bindings/TextCodec.cpp diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index f2e7f60d1c..cf37a5eef7 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -200,6 +200,7 @@ src/bun.js/bindings/sizes.zig src/bun.js/bindings/SourceProvider.zig src/bun.js/bindings/SourceType.zig src/bun.js/bindings/static_export.zig +src/bun.js/bindings/StringBuilder.zig src/bun.js/bindings/SystemError.zig src/bun.js/bindings/TextCodec.zig src/bun.js/bindings/URL.zig diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index cbbd196026..9c85d8eacb 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -644,6 +644,38 @@ declare module "bun" { * ``` */ export function parse(input: string): unknown; + + /** + * Convert a JavaScript value into a YAML string. Strings are double quoted if they contain keywords, non-printable or + * escaped characters, or if a YAML parser would parse them as numbers. Anchors and aliases are inferred from objects, allowing cycles. + * + * @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 string containing the YAML document. + * + * @example + * ```ts + * import { YAML } from "bun"; + * + * const input = { + * abc: "def" + * }; + * console.log(YAML.stringify(input)); + * // # output + * // abc: def + * + * const cycle = {}; + * cycle.obj = cycle; + * console.log(YAML.stringify(cycle)); + * // # output + * // &root + * // obj: + * // *root + */ + export function stringify(input: unknown, replacer?: undefined | null, space?: string | number): string; } /** diff --git a/src/bun.js/api/YAMLObject.zig b/src/bun.js/api/YAMLObject.zig index 049e9dc14a..0bb9d18f23 100644 --- a/src/bun.js/api/YAMLObject.zig +++ b/src/bun.js/api/YAMLObject.zig @@ -1,5 +1,5 @@ pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue { - const object = JSValue.createEmptyObject(globalThis, 1); + const object = JSValue.createEmptyObject(globalThis, 2); object.put( globalThis, ZigString.static("parse"), @@ -10,10 +10,898 @@ pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue { parse, ), ); + object.put( + globalThis, + ZigString.static("stringify"), + jsc.createCallback( + globalThis, + ZigString.static("stringify"), + 3, + stringify, + ), + ); return object; } +pub fn stringify(global: *JSGlobalObject, callFrame: *jsc.CallFrame) JSError!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("YAML.stringify does not support the replacer argument", .{}); + } + + var scope: bun.AllocationScope = .init(bun.default_allocator); + defer scope.deinit(); + + var stringifier: Stringifier = try .init(scope.allocator(), global, space_value); + defer stringifier.deinit(); + + stringifier.findAnchorsAndAliases(global, value, .root) catch |err| return switch (err) { + error.OutOfMemory, error.JSError => |js_err| js_err, + error.StackOverflow => global.throwStackOverflow(), + }; + + stringifier.stringify(global, value) catch |err| return switch (err) { + error.OutOfMemory, error.JSError => |js_err| js_err, + error.StackOverflow => global.throwStackOverflow(), + }; + + return stringifier.builder.toString(global); +} + +const Stringifier = struct { + stack_check: bun.StackCheck, + builder: wtf.StringBuilder, + indent: usize, + + known_collections: std.AutoHashMap(JSValue, AnchorAlias), + array_item_counter: usize, + prop_names: bun.StringHashMap(usize), + + space: Space, + + pub const Space = union(enum) { + minified, + number: u32, + 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) }; + } + + if (space_value.isString()) { + const str = try space_value.toBunString(global); + if (str.length() == 0) { + str.deref(); + return .minified; + } + return .{ .str = str }; + } + + return .minified; + } + + pub fn deinit(this: *const Space) void { + switch (this.*) { + .minified => {}, + .number => {}, + .str => |str| { + str.deref(); + }, + } + } + }; + + const AnchorOrigin = enum { + root, + array_item, + prop_value, + }; + + const AnchorAlias = struct { + anchored: bool, + used: bool, + name: Name, + + pub fn init(origin: ValueOrigin) AnchorAlias { + return .{ + .anchored = false, + .used = false, + .name = switch (origin) { + .root => .root, + .array_item => .{ .array_item = 0 }, + .prop_value => .{ .prop_value = .{ .prop_name = origin.prop_value, .counter = 0 } }, + }, + }; + } + + pub const Name = union(AnchorOrigin) { + // only one root anchor is possible + root, + array_item: usize, + prop_value: struct { + prop_name: String, + // added after the name + counter: usize, + }, + }; + }; + + pub fn init(allocator: std.mem.Allocator, global: *JSGlobalObject, space_value: JSValue) JSError!Stringifier { + var prop_names: bun.StringHashMap(usize) = .init(allocator); + // always rename anchors named "root" to avoid collision with + // root anchor/alias + try prop_names.put("root", 0); + + return .{ + .stack_check = .init(), + .builder = .init(), + .indent = 0, + .known_collections = .init(allocator), + .array_item_counter = 0, + .prop_names = prop_names, + .space = try .init(global, space_value), + }; + } + + pub fn deinit(this: *Stringifier) void { + this.builder.deinit(); + this.known_collections.deinit(); + this.prop_names.deinit(); + this.space.deinit(); + } + + const ValueOrigin = union(AnchorOrigin) { + root, + array_item, + prop_value: String, + }; + + pub fn findAnchorsAndAliases(this: *Stringifier, global: *JSGlobalObject, value: JSValue, origin: ValueOrigin) StringifyError!void { + if (!this.stack_check.isSafeToRecurse()) { + return error.StackOverflow; + } + + const unwrapped = try value.unwrapBoxedPrimitive(global); + + if (unwrapped.isNull()) { + return; + } + + if (unwrapped.isNumber()) { + return; + } + + if (unwrapped.isBigInt()) { + return global.throw("YAML.stringify cannot serialize BigInt", .{}); + } + + if (unwrapped.isBoolean()) { + return; + } + + if (unwrapped.isString()) { + return; + } + + if (comptime Environment.ci_assert) { + bun.assertWithLocation(unwrapped.isObject(), @src()); + } + + const object_entry = try this.known_collections.getOrPut(unwrapped); + if (object_entry.found_existing) { + // this will become an alias. increment counters here because + // now the anchor/alias is confirmed used. + + if (object_entry.value_ptr.used) { + return; + } + + object_entry.value_ptr.used = true; + + switch (object_entry.value_ptr.name) { + .root => { + // only one possible + }, + .array_item => |*counter| { + counter.* = this.array_item_counter; + this.array_item_counter += 1; + }, + .prop_value => |*prop_value| { + const name_entry = try this.prop_names.getOrPut(prop_value.prop_name.byteSlice()); + if (name_entry.found_existing) { + name_entry.value_ptr.* += 1; + } else { + name_entry.value_ptr.* = 0; + } + + prop_value.counter = name_entry.value_ptr.*; + }, + } + return; + } + + object_entry.value_ptr.* = .init(origin); + + if (unwrapped.isArray()) { + var iter = try unwrapped.arrayIterator(global); + while (try iter.next()) |item| { + if (item.isUndefined() or item.isSymbol() or item.isFunction()) { + continue; + } + + try this.findAnchorsAndAliases(global, item, .array_item); + } + return; + } + + var iter: jsc.JSPropertyIterator(.{ .skip_empty_name = false, .include_value = true }) = try .init( + global, + try unwrapped.toObject(global), + ); + defer iter.deinit(); + + while (try iter.next()) |prop_name| { + if (iter.value.isUndefined() or iter.value.isSymbol() or iter.value.isFunction()) { + continue; + } + try this.findAnchorsAndAliases(global, iter.value, .{ .prop_value = prop_name }); + } + } + + const StringifyError = JSError || bun.StackOverflow; + + pub fn stringify(this: *Stringifier, global: *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, "-.inf"); + // } else if (std.math.isPositiveInf(num)) { + // builder.append(.latin1, "+.inf"); + } else if (std.math.isInf(num)) { + this.builder.append(.latin1, ".inf"); + } else if (std.math.isNan(num)) { + this.builder.append(.latin1, ".nan"); + } else if (std.math.isNegativeZero(num)) { + this.builder.append(.latin1, "-0"); + } else if (std.math.isPositiveZero(num)) { + this.builder.append(.latin1, "+0"); + } else { + this.builder.append(.double, num); + } + return; + } + + if (unwrapped.isBigInt()) { + return global.throw("YAML.stringify cannot serialize BigInt", .{}); + } + + if (unwrapped.isBoolean()) { + if (unwrapped.asBoolean()) { + this.builder.append(.latin1, "true"); + } else { + this.builder.append(.latin1, "false"); + } + return; + } + + if (unwrapped.isString()) { + const value_str = try unwrapped.toBunString(global); + defer value_str.deref(); + + this.appendString(value_str); + return; + } + + if (comptime Environment.ci_assert) { + bun.assertWithLocation(unwrapped.isObject(), @src()); + } + + const has_anchor: ?*AnchorAlias = has_anchor: { + const anchor = this.known_collections.getPtr(unwrapped) orelse { + break :has_anchor null; + }; + + if (!anchor.used) { + break :has_anchor null; + } + + break :has_anchor anchor; + }; + + if (has_anchor) |anchor| { + this.builder.append(.lchar, if (anchor.anchored) '*' else '&'); + + switch (anchor.name) { + .root => { + this.builder.append(.latin1, "root"); + }, + .array_item => { + this.builder.append(.latin1, "item"); + this.builder.append(.usize, anchor.name.array_item); + }, + .prop_value => |prop_value| { + if (prop_value.prop_name.length() == 0) { + this.builder.append(.latin1, "value"); + this.builder.append(.usize, prop_value.counter); + } else { + this.builder.append(.string, anchor.name.prop_value.prop_name); + if (anchor.name.prop_value.counter != 0) { + this.builder.append(.usize, anchor.name.prop_value.counter); + } + } + }, + } + + if (anchor.anchored) { + return; + } + + switch (this.space) { + .minified => { + this.builder.append(.lchar, ' '); + }, + .number, .str => { + this.newline(); + }, + } + anchor.anchored = true; + } + + if (unwrapped.isArray()) { + var iter = try unwrapped.arrayIterator(global); + + if (iter.len == 0) { + this.builder.append(.latin1, "[]"); + return; + } + + switch (this.space) { + .minified => { + this.builder.append(.lchar, '['); + var first = true; + while (try iter.next()) |item| { + if (item.isUndefined() or item.isSymbol() or item.isFunction()) { + continue; + } + + if (!first) { + this.builder.append(.lchar, ','); + } + first = false; + + try this.stringify(global, item); + } + this.builder.append(.lchar, ']'); + }, + .number, .str => { + this.builder.ensureUnusedCapacity(iter.len * "- ".len); + var first = true; + while (try iter.next()) |item| { + if (item.isUndefined() or item.isSymbol() or item.isFunction()) { + continue; + } + + if (!first) { + this.newline(); + } + first = false; + + this.builder.append(.latin1, "- "); + + // don't need to print a newline here for any value + + this.indent += 1; + try this.stringify(global, item); + this.indent -= 1; + } + }, + } + + return; + } + + var iter: jsc.JSPropertyIterator(.{ .skip_empty_name = false, .include_value = true }) = try .init( + global, + try unwrapped.toObject(global), + ); + defer iter.deinit(); + + if (iter.len == 0) { + this.builder.append(.latin1, "{}"); + return; + } + + switch (this.space) { + .minified => { + this.builder.append(.lchar, '{'); + 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.appendString(prop_name); + this.builder.append(.latin1, ": "); + + try this.stringify(global, iter.value); + } + this.builder.append(.lchar, '}'); + }, + .number, .str => { + this.builder.ensureUnusedCapacity(iter.len * ": ".len); + + 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.newline(); + } + first = false; + + this.appendString(prop_name); + this.builder.append(.latin1, ": "); + + this.indent += 1; + + if (propValueNeedsNewline(iter.value)) { + this.newline(); + } + + try this.stringify(global, iter.value); + this.indent -= 1; + } + }, + } + } + + /// Does this object property value need a newline? True for arrays and objects. + fn propValueNeedsNewline(value: JSValue) bool { + return !value.isNumber() and !value.isBoolean() and !value.isNull() and !value.isString(); + } + + fn newline(this: *Stringifier) void { + const indent_count = this.indent; + + switch (this.space) { + .minified => {}, + .number => |space_num| { + this.builder.append(.lchar, '\n'); + this.builder.ensureUnusedCapacity(indent_count * space_num); + for (0..indent_count * 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; + + this.builder.ensureUnusedCapacity(indent_count * clamped.length()); + for (0..indent_count) |_| { + this.builder.append(.string, clamped); + } + }, + } + } + + fn appendDoubleQuotedString(this: *Stringifier, str: String) void { + this.builder.append(.lchar, '"'); + + for (0..str.length()) |i| { + const c = str.charAt(i); + + switch (c) { + 0x00 => this.builder.append(.latin1, "\\0"), + 0x01 => this.builder.append(.latin1, "\\x01"), + 0x02 => this.builder.append(.latin1, "\\x02"), + 0x03 => this.builder.append(.latin1, "\\x03"), + 0x04 => this.builder.append(.latin1, "\\x04"), + 0x05 => this.builder.append(.latin1, "\\x05"), + 0x06 => this.builder.append(.latin1, "\\x06"), + 0x07 => this.builder.append(.latin1, "\\a"), // bell + 0x08 => this.builder.append(.latin1, "\\b"), // backspace + 0x09 => this.builder.append(.latin1, "\\t"), // tab + 0x0a => this.builder.append(.latin1, "\\n"), // line feed + 0x0b => this.builder.append(.latin1, "\\v"), // vertical tab + 0x0c => this.builder.append(.latin1, "\\f"), // form feed + 0x0d => this.builder.append(.latin1, "\\r"), // carriage return + 0x0e => this.builder.append(.latin1, "\\x0e"), + 0x0f => this.builder.append(.latin1, "\\x0f"), + 0x10 => this.builder.append(.latin1, "\\x10"), + 0x11 => this.builder.append(.latin1, "\\x11"), + 0x12 => this.builder.append(.latin1, "\\x12"), + 0x13 => this.builder.append(.latin1, "\\x13"), + 0x14 => this.builder.append(.latin1, "\\x14"), + 0x15 => this.builder.append(.latin1, "\\x15"), + 0x16 => this.builder.append(.latin1, "\\x16"), + 0x17 => this.builder.append(.latin1, "\\x17"), + 0x18 => this.builder.append(.latin1, "\\x18"), + 0x19 => this.builder.append(.latin1, "\\x19"), + 0x1a => this.builder.append(.latin1, "\\x1a"), + 0x1b => this.builder.append(.latin1, "\\e"), // escape + 0x1c => this.builder.append(.latin1, "\\x1c"), + 0x1d => this.builder.append(.latin1, "\\x1d"), + 0x1e => this.builder.append(.latin1, "\\x1e"), + 0x1f => this.builder.append(.latin1, "\\x1f"), + 0x22 => this.builder.append(.latin1, "\\\""), // " + 0x5c => this.builder.append(.latin1, "\\\\"), // \ + 0x7f => this.builder.append(.latin1, "\\x7f"), // delete + 0x85 => this.builder.append(.latin1, "\\N"), // next line + 0xa0 => this.builder.append(.latin1, "\\_"), // non-breaking space + 0xa8 => this.builder.append(.latin1, "\\L"), // line separator + 0xa9 => this.builder.append(.latin1, "\\P"), // paragraph separator + + 0x20...0x21, + 0x23...0x5b, + 0x5d...0x7e, + 0x80...0x84, + 0x86...0x9f, + 0xa1...0xa7, + 0xaa...std.math.maxInt(u16), + => this.builder.append(.uchar, c), + } + } + + this.builder.append(.lchar, '"'); + } + + fn appendString(this: *Stringifier, str: String) void { + if (stringNeedsQuotes(str)) { + this.appendDoubleQuotedString(str); + return; + } + this.builder.append(.string, str); + } + + fn stringNeedsQuotes(str: String) bool { + if (str.isEmpty()) { + return true; + } + + switch (str.charAt(str.length() - 1)) { + // whitespace characters + ' ', + '\t', + '\n', + '\r', + => return true, + else => {}, + } + + switch (str.charAt(0)) { + // starting with indicators or whitespace requires quotes + '&', + '*', + '?', + '|', + '-', + '<', + '>', + '!', + '%', + '@', + ' ', + '\t', + '\n', + '\r', + '#', + => return true, + + else => {}, + } + + const keywords = &.{ + "true", + "True", + "TRUE", + "false", + "False", + "FALSE", + "yes", + "Yes", + "YES", + "no", + "No", + "NO", + "on", + "On", + "ON", + "off", + "Off", + "OFF", + "n", + "N", + "y", + "Y", + "null", + "Null", + "NULL", + "~", + ".inf", + ".Inf", + ".INF", + ".nan", + ".NaN", + ".NAN", + }; + + inline for (keywords) |keyword| { + if (str.eqlComptime(keyword)) { + return true; + } + } + + var i: usize = 0; + while (i < str.length()) { + switch (str.charAt(i)) { + // flow indicators need to be quoted always + '{', + '}', + '[', + ']', + ',', + => return true, + + ':', + => { + if (i + 1 < str.length()) { + switch (str.charAt(i + 1)) { + ' ', + '\t', + '\n', + '\r', + => return true, + else => {}, + } + } + i += 1; + }, + + '#', + '`', + '\'', + => return true, + + '-' => { + if (i + 2 < str.length() and str.charAt(i + 1) == '-' and str.charAt(i + 2) == '-') { + if (i + 3 >= str.length()) { + return true; + } + switch (str.charAt(i + 3)) { + ' ', + '\t', + '\r', + '\n', + '[', + ']', + '{', + '}', + ',', + => return true, + else => {}, + } + } + + if (i == 0 and stringIsNumber(str, &i)) { + return true; + } + i += 1; + }, + '.' => { + if (i + 2 < str.length() and str.charAt(i + 1) == '.' and str.charAt(i + 2) == '.') { + if (i + 3 >= str.length()) { + return true; + } + switch (str.charAt(i + 3)) { + ' ', + '\t', + '\r', + '\n', + '[', + ']', + '{', + '}', + ',', + => return true, + else => {}, + } + } + + if (i == 0 and stringIsNumber(str, &i)) { + return true; + } + i += 1; + }, + + '0'...'9' => { + if (i == 0 and stringIsNumber(str, &i)) { + return true; + } + i += 1; + }, + + 0x00...0x1f, + 0x22, + 0x7f, + 0x85, + 0xa0, + 0xa8, + 0xa9, + => return true, + + else => { + i += 1; + }, + } + } + + return false; + } + + fn stringIsNumber(str: String, offset: *usize) bool { + const start = offset.*; + var i = start; + + var @"+" = false; + var @"-" = false; + var e = false; + var dot = false; + + var base: enum { dec, hex, oct } = .dec; + + next: switch (str.charAt(i)) { + '.' => { + if (dot or base != .dec) { + offset.* = i; + return false; + } + dot = true; + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + '+' => { + if (@"+") { + offset.* = i; + return false; + } + @"+" = true; + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + '-' => { + if (@"-") { + offset.* = i; + return false; + } + @"-" = true; + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + '0' => { + if (i == start) { + if (i + 1 < str.length()) { + const nc = str.charAt(i + 1); + if (nc == 'x' or nc == 'X') { + base = .hex; + } else if (nc == 'o' or nc == 'O') { + base = .oct; + } else { + offset.* = i; + return false; + } + i += 1; + } else { + return true; + } + } + + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + 'e', + 'E', + => { + if (base == .oct or (e and base == .dec)) { + offset.* = i; + return false; + } + e = true; + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + 'a'...'d', + 'f', + 'A'...'D', + 'F', + => { + if (base != .hex) { + offset.* = i; + return false; + } + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + '1'...'9' => { + i += 1; + if (i < str.length()) { + continue :next str.charAt(i); + } + return true; + }, + + else => { + offset.* = i; + return false; + }, + } + } +}; + pub fn parse( global: *jsc.JSGlobalObject, callFrame: *jsc.CallFrame, @@ -23,8 +911,10 @@ pub fn parse( const input_value = callFrame.argumentsAsArray(1)[0]; - const input_str = try input_value.toBunString(global); - const input = input_str.toSlice(arena.allocator()); + const input: jsc.Node.BlobOrStringOrBuffer = try jsc.Node.BlobOrStringOrBuffer.fromJS(global, arena.allocator(), input_value) orelse input: { + const str = try input_value.toBunString(global); + break :input .{ .string_or_buffer = .{ .string = str.toSlice(arena.allocator()) } }; + }; defer input.deinit(); var log = logger.Log.init(bun.default_allocator); @@ -75,12 +965,18 @@ const ParserCtx = struct { ctx.result = .zero; return; }, + error.StackOverflow => { + ctx.result = ctx.global.throwStackOverflow() catch .zero; + return; + }, }; } - pub fn toJS(ctx: *ParserCtx, args: *MarkedArgumentBuffer, expr: Expr) JSError!JSValue { + const ToJSError = JSError || bun.StackOverflow; + + pub fn toJS(ctx: *ParserCtx, args: *MarkedArgumentBuffer, expr: Expr) ToJSError!JSValue { if (!ctx.stack_check.isSafeToRecurse()) { - return ctx.global.throwStackOverflow(); + return error.StackOverflow; } switch (expr.data) { .e_null => return .null, @@ -143,7 +1039,9 @@ const ParserCtx = struct { const std = @import("std"); const bun = @import("bun"); +const Environment = bun.Environment; const JSError = bun.JSError; +const String = bun.String; const default_allocator = bun.default_allocator; const logger = bun.logger; const YAML = bun.interchange.yaml.YAML; @@ -156,3 +1054,4 @@ const JSGlobalObject = jsc.JSGlobalObject; const JSValue = jsc.JSValue; const MarkedArgumentBuffer = jsc.MarkedArgumentBuffer; const ZigString = jsc.ZigString; +const wtf = bun.jsc.wtf; diff --git a/src/bun.js/bindings/BunString.cpp b/src/bun.js/bindings/BunString.cpp index 0383f1fed2..9e399feffb 100644 --- a/src/bun.js/bindings/BunString.cpp +++ b/src/bun.js/bindings/BunString.cpp @@ -717,6 +717,21 @@ WTF::String BunString::toWTFString() const return WTF::String(); } +void BunString::appendToBuilder(WTF::StringBuilder& builder) const +{ + if (this->tag == BunStringTag::WTFStringImpl) { + builder.append(this->impl.wtf); + return; + } + + if (this->tag == BunStringTag::ZigString || this->tag == BunStringTag::StaticZigString) { + Zig::appendToBuilder(this->impl.zig, builder); + return; + } + + // append nothing for BunStringTag::Dead and BunStringTag::Empty +} + WTF::String BunString::toWTFString(ZeroCopyTag) const { if (this->tag == BunStringTag::ZigString) { diff --git a/src/bun.js/bindings/CatchScope.zig b/src/bun.js/bindings/CatchScope.zig index 63e037a56e..22c6014159 100644 --- a/src/bun.js/bindings/CatchScope.zig +++ b/src/bun.js/bindings/CatchScope.zig @@ -1,5 +1,5 @@ // TODO determine size and alignment automatically -const size = 56; +const size = if (Environment.allow_assert or Environment.enable_asan) 56 else 8; const alignment = 8; /// Binding for JSC::CatchScope. This should be used rarely, only at translation boundaries between diff --git a/src/bun.js/bindings/CatchScopeBinding.cpp b/src/bun.js/bindings/CatchScopeBinding.cpp index 6abf1e75b3..28b57370ad 100644 --- a/src/bun.js/bindings/CatchScopeBinding.cpp +++ b/src/bun.js/bindings/CatchScopeBinding.cpp @@ -2,6 +2,17 @@ using JSC::CatchScope; +#if ENABLE(EXCEPTION_SCOPE_VERIFICATION) +#define ExpectedCatchScopeSize 56 +#define ExpectedCatchScopeAlignment 8 +#else +#define ExpectedCatchScopeSize 8 +#define ExpectedCatchScopeAlignment 8 +#endif + +static_assert(sizeof(CatchScope) == ExpectedCatchScopeSize, "CatchScope.zig assumes CatchScope is 56 bytes"); +static_assert(alignof(CatchScope) == ExpectedCatchScopeAlignment, "CatchScope.zig assumes CatchScope is 8-byte aligned"); + extern "C" void CatchScope__construct( void* ptr, JSC::JSGlobalObject* globalObject, diff --git a/src/bun.js/bindings/JSPropertyIterator.cpp b/src/bun.js/bindings/JSPropertyIterator.cpp index 5b066bf933..979d3c6dcc 100644 --- a/src/bun.js/bindings/JSPropertyIterator.cpp +++ b/src/bun.js/bindings/JSPropertyIterator.cpp @@ -130,18 +130,19 @@ static EncodedJSValue getOwnProxyObject(JSPropertyIterator* iter, JSObject* obje extern "C" EncodedJSValue Bun__JSPropertyIterator__getNameAndValue(JSPropertyIterator* iter, JSC::JSGlobalObject* globalObject, JSC::JSObject* object, BunString* propertyName, size_t i) { - const auto& prop = iter->properties->propertyNameVector()[i]; - if (iter->isSpecialProxy) [[unlikely]] { - return getOwnProxyObject(iter, object, prop, propertyName); - } - auto& vm = iter->vm; auto scope = DECLARE_THROW_SCOPE(vm); + + const auto& prop = iter->properties->propertyNameVector()[i]; + if (iter->isSpecialProxy) [[unlikely]] { + RELEASE_AND_RETURN(scope, getOwnProxyObject(iter, object, prop, propertyName)); + } + // This has to be get because we may need to call on prototypes // If we meant for this to only run for own keys, the property name would not be included in the array. PropertySlot slot(object, PropertySlot::InternalMethodType::Get); if (!object->getPropertySlot(globalObject, prop, slot)) { - return {}; + RELEASE_AND_RETURN(scope, {}); } RETURN_IF_EXCEPTION(scope, {}); @@ -154,13 +155,14 @@ extern "C" EncodedJSValue Bun__JSPropertyIterator__getNameAndValue(JSPropertyIte extern "C" EncodedJSValue Bun__JSPropertyIterator__getNameAndValueNonObservable(JSPropertyIterator* iter, JSC::JSGlobalObject* globalObject, JSC::JSObject* object, BunString* propertyName, size_t i) { - const auto& prop = iter->properties->propertyNameVector()[i]; - if (iter->isSpecialProxy) [[unlikely]] { - return getOwnProxyObject(iter, object, prop, propertyName); - } auto& vm = iter->vm; auto scope = DECLARE_THROW_SCOPE(vm); + const auto& prop = iter->properties->propertyNameVector()[i]; + if (iter->isSpecialProxy) [[unlikely]] { + RELEASE_AND_RETURN(scope, getOwnProxyObject(iter, object, prop, propertyName)); + } + PropertySlot slot(object, PropertySlot::InternalMethodType::VMInquiry, vm.ptr()); auto has = object->getNonIndexPropertySlot(globalObject, prop, slot); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/bun.js/bindings/JSValue.zig b/src/bun.js/bindings/JSValue.zig index 8ec5b16077..b9f096f5fd 100644 --- a/src/bun.js/bindings/JSValue.zig +++ b/src/bun.js/bindings/JSValue.zig @@ -1271,6 +1271,17 @@ pub const JSValue = enum(i64) { return if (this.isObject()) this.uncheckedPtrCast(JSObject) else null; } + /// Unwraps Number, Boolean, String, and BigInt objects to their primitive forms. + pub fn unwrapBoxedPrimitive(this: JSValue, global: *JSGlobalObject) JSError!JSValue { + var scope: CatchScope = undefined; + scope.init(global, @src()); + defer scope.deinit(); + const result = JSC__JSValue__unwrapBoxedPrimitive(global, this); + try scope.returnIfException(); + return result; + } + extern fn JSC__JSValue__unwrapBoxedPrimitive(*JSGlobalObject, JSValue) JSValue; + extern fn JSC__JSValue__getPrototype(this: JSValue, globalObject: *JSGlobalObject) JSValue; pub fn getPrototype(this: JSValue, globalObject: *JSGlobalObject) JSValue { return JSC__JSValue__getPrototype(this, globalObject); diff --git a/src/bun.js/bindings/StringBuilder.zig b/src/bun.js/bindings/StringBuilder.zig new file mode 100644 index 0000000000..a42e189a84 --- /dev/null +++ b/src/bun.js/bindings/StringBuilder.zig @@ -0,0 +1,91 @@ +const StringBuilder = @This(); + +const size = 24; +const alignment = 8; + +bytes: [size]u8 align(alignment), + +pub inline fn init() StringBuilder { + var this: StringBuilder = undefined; + StringBuilder__init(&this.bytes); + return this; +} +extern fn StringBuilder__init(*anyopaque) void; + +pub fn deinit(this: *StringBuilder) void { + StringBuilder__deinit(&this.bytes); +} +extern fn StringBuilder__deinit(*anyopaque) void; + +const Append = enum { + latin1, + utf16, + double, + int, + usize, + string, + lchar, + uchar, + quoted_json_string, + + pub fn Type(comptime this: Append) type { + return switch (this) { + .latin1 => []const u8, + .utf16 => []const u16, + .double => f64, + .int => i32, + .usize => usize, + .string => String, + .lchar => u8, + .uchar => u16, + .quoted_json_string => String, + }; + } +}; + +pub fn append(this: *StringBuilder, comptime append_type: Append, value: append_type.Type()) void { + switch (comptime append_type) { + .latin1 => StringBuilder__appendLatin1(&this.bytes, value.ptr, value.len), + .utf16 => StringBuilder__appendUtf16(&this.bytes, value.ptr, value.len), + .double => StringBuilder__appendDouble(&this.bytes, value), + .int => StringBuilder__appendInt(&this.bytes, value), + .usize => StringBuilder__appendUsize(&this.bytes, value), + .string => StringBuilder__appendString(&this.bytes, value), + .lchar => StringBuilder__appendLChar(&this.bytes, value), + .uchar => StringBuilder__appendUChar(&this.bytes, value), + .quoted_json_string => StringBuilder__appendQuotedJsonString(&this.bytes, value), + } +} +extern fn StringBuilder__appendLatin1(*anyopaque, str: [*]const u8, len: usize) void; +extern fn StringBuilder__appendUtf16(*anyopaque, str: [*]const u16, len: usize) void; +extern fn StringBuilder__appendDouble(*anyopaque, num: f64) void; +extern fn StringBuilder__appendInt(*anyopaque, num: i32) void; +extern fn StringBuilder__appendUsize(*anyopaque, num: usize) void; +extern fn StringBuilder__appendString(*anyopaque, str: String) void; +extern fn StringBuilder__appendLChar(*anyopaque, c: u8) void; +extern fn StringBuilder__appendUChar(*anyopaque, c: u16) void; +extern fn StringBuilder__appendQuotedJsonString(*anyopaque, str: String) void; + +pub fn toString(this: *StringBuilder, global: *JSGlobalObject) JSError!JSValue { + var scope: jsc.CatchScope = undefined; + scope.init(global, @src()); + defer scope.deinit(); + + const result = StringBuilder__toString(&this.bytes, global); + try scope.returnIfException(); + return result; +} +extern fn StringBuilder__toString(*anyopaque, global: *JSGlobalObject) JSValue; + +pub fn ensureUnusedCapacity(this: *StringBuilder, additional: usize) void { + StringBuilder__ensureUnusedCapacity(&this.bytes, additional); +} +extern fn StringBuilder__ensureUnusedCapacity(*anyopaque, usize) void; + +const bun = @import("bun"); +const JSError = bun.JSError; +const String = bun.String; + +const jsc = bun.jsc; +const JSGlobalObject = jsc.JSGlobalObject; +const JSValue = jsc.JSValue; diff --git a/src/bun.js/bindings/StringBuilderBinding.cpp b/src/bun.js/bindings/StringBuilderBinding.cpp new file mode 100644 index 0000000000..108ece7191 --- /dev/null +++ b/src/bun.js/bindings/StringBuilderBinding.cpp @@ -0,0 +1,81 @@ +#include "root.h" +#include "BunString.h" +#include "headers-handwritten.h" + +static_assert(sizeof(WTF::StringBuilder) == 24, "StringBuilder.zig assumes WTF::StringBuilder is 24 bytes"); +static_assert(alignof(WTF::StringBuilder) == 8, "StringBuilder.zig assumes WTF::StringBuilder is 8-byte aligned"); + +extern "C" void StringBuilder__init(WTF::StringBuilder* ptr) +{ + new (ptr) WTF::StringBuilder(OverflowPolicy::RecordOverflow); +} + +extern "C" void StringBuilder__deinit(WTF::StringBuilder* builder) +{ + builder->~StringBuilder(); +} + +extern "C" void StringBuilder__appendLatin1(WTF::StringBuilder* builder, LChar const* ptr, size_t len) +{ + builder->append({ ptr, len }); +} + +extern "C" void StringBuilder__appendUtf16(WTF::StringBuilder* builder, UChar const* ptr, size_t len) +{ + builder->append({ ptr, len }); +} + +extern "C" void StringBuilder__appendDouble(WTF::StringBuilder* builder, double num) +{ + builder->append(num); +} + +extern "C" void StringBuilder__appendInt(WTF::StringBuilder* builder, int32_t num) +{ + builder->append(num); +} + +extern "C" void StringBuilder__appendUsize(WTF::StringBuilder* builder, size_t num) +{ + builder->append(num); +} + +extern "C" void StringBuilder__appendString(WTF::StringBuilder* builder, BunString str) +{ + str.appendToBuilder(*builder); +} + +extern "C" void StringBuilder__appendLChar(WTF::StringBuilder* builder, LChar c) +{ + builder->append(c); +} + +extern "C" void StringBuilder__appendUChar(WTF::StringBuilder* builder, UChar c) +{ + builder->append(c); +} + +extern "C" void StringBuilder__appendQuotedJsonString(WTF::StringBuilder* builder, BunString str) +{ + auto string = str.toWTFString(BunString::ZeroCopy); + builder->appendQuotedJSONString(string); +} + +extern "C" JSC::EncodedJSValue StringBuilder__toString(WTF::StringBuilder* builder, JSC::JSGlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (builder->hasOverflowed()) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); + return JSC::JSValue::encode({}); + } + + auto str = builder->toString(); + return JSC::JSValue::encode(JSC::jsString(vm, str)); +} + +extern "C" void StringBuilder__ensureUnusedCapacity(WTF::StringBuilder* builder, size_t additional) +{ + builder->reserveCapacity(builder->length() + additional); +} diff --git a/src/bun.js/bindings/WTF.zig b/src/bun.js/bindings/WTF.zig index 2804b9d10d..7fa5aeff65 100644 --- a/src/bun.js/bindings/WTF.zig +++ b/src/bun.js/bindings/WTF.zig @@ -36,6 +36,8 @@ pub const WTF = struct { return buffer[0..@intCast(res)]; } + + pub const StringBuilder = @import("./StringBuilder.zig"); }; const bun = @import("bun"); diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 7cd1a672a5..4e518ea354 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -36,6 +36,7 @@ #include "JavaScriptCore/JSArrayBuffer.h" #include "JavaScriptCore/JSArrayInlines.h" #include "JavaScriptCore/ErrorInstanceInlines.h" +#include "JavaScriptCore/BigIntObject.h" #include "JavaScriptCore/JSCallbackObject.h" #include "JavaScriptCore/JSClassRef.h" @@ -2096,6 +2097,28 @@ BunString WebCore__DOMURL__fileSystemPath(WebCore::DOMURL* arg0, int* errorCode) return BunString { BunStringTag::Dead, nullptr }; } +// Taken from unwrapBoxedPrimitive in JSONObject.cpp in WebKit +extern "C" JSC::EncodedJSValue JSC__JSValue__unwrapBoxedPrimitive(JSGlobalObject* globalObject, EncodedJSValue encodedValue) +{ + JSValue value = JSValue::decode(encodedValue); + + if (!value.isObject()) { + return JSValue::encode(value); + } + + JSObject* object = asObject(value); + + if (object->inherits()) { + return JSValue::encode(jsNumber(object->toNumber(globalObject))); + } + if (object->inherits()) + return JSValue::encode(object->toString(globalObject)); + if (object->inherits() || object->inherits()) + return JSValue::encode(jsCast(object)->internalValue()); + + return JSValue::encode(object); +} + extern "C" JSC::EncodedJSValue ZigString__toJSONObject(const ZigString* strPtr, JSC::JSGlobalObject* globalObject) { ASSERT_NO_PENDING_EXCEPTION(globalObject); diff --git a/src/bun.js/bindings/headers-handwritten.h b/src/bun.js/bindings/headers-handwritten.h index 693b2dbb0e..b52e538af1 100644 --- a/src/bun.js/bindings/headers-handwritten.h +++ b/src/bun.js/bindings/headers-handwritten.h @@ -81,6 +81,8 @@ typedef struct BunString { bool isEmpty() const; + void appendToBuilder(WTF::StringBuilder& builder) const; + } BunString; typedef struct ZigErrorType { diff --git a/src/bun.js/bindings/helpers.h b/src/bun.js/bindings/helpers.h index c726b4b312..620d12efa5 100644 --- a/src/bun.js/bindings/helpers.h +++ b/src/bun.js/bindings/helpers.h @@ -183,6 +183,24 @@ static const WTF::String toStringCopy(ZigString str) } } +static void appendToBuilder(ZigString str, WTF::StringBuilder& builder) +{ + if (str.len == 0 || str.ptr == nullptr) { + return; + } + if (isTaggedUTF8Ptr(str.ptr)) [[unlikely]] { + WTF::String converted = WTF::String::fromUTF8ReplacingInvalidSequences(std::span { untag(str.ptr), str.len }); + builder.append(converted); + return; + } + if (isTaggedUTF16Ptr(str.ptr)) { + builder.append({ reinterpret_cast(untag(str.ptr)), str.len }); + return; + } + + builder.append({ untag(str.ptr), str.len }); +} + static WTF::String toStringNotConst(ZigString str) { return toString(str); } static const JSC::JSString* toJSString(ZigString str, JSC::JSGlobalObject* global) diff --git a/src/bun.zig b/src/bun.zig index b3d362a867..d97be8c8c3 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -3720,7 +3720,7 @@ pub noinline fn throwStackOverflow() StackOverflow!void { @branchHint(.cold); return error.StackOverflow; } -const StackOverflow = error{StackOverflow}; +pub const StackOverflow = error{StackOverflow}; pub const S3 = @import("./s3/client.zig"); diff --git a/src/string.zig b/src/string.zig index 02abda0165..b7524f0792 100644 --- a/src/string.zig +++ b/src/string.zig @@ -866,19 +866,8 @@ pub const String = extern struct { bun.assert(index < this.length()); } return switch (this.tag) { - .WTFStringImpl => if (this.value.WTFStringImpl.is8Bit()) @intCast(this.value.WTFStringImpl.utf8Slice()[index]) else this.value.WTFStringImpl.utf16Slice()[index], - .ZigString, .StaticZigString => if (!this.value.ZigString.is16Bit()) @intCast(this.value.ZigString.slice()[index]) else this.value.ZigString.utf16Slice()[index], - else => 0, - }; - } - - pub fn charAtU8(this: String, index: usize) u8 { - if (comptime bun.Environment.allow_assert) { - bun.assert(index < this.length()); - } - return switch (this.tag) { - .WTFStringImpl => if (this.value.WTFStringImpl.is8Bit()) this.value.WTFStringImpl.utf8Slice()[index] else @truncate(this.value.WTFStringImpl.utf16Slice()[index]), - .ZigString, .StaticZigString => if (!this.value.ZigString.is16Bit()) this.value.ZigString.slice()[index] else @truncate(this.value.ZigString.utf16SliceAligned()[index]), + .WTFStringImpl => if (this.value.WTFStringImpl.is8Bit()) this.value.WTFStringImpl.latin1Slice()[index] else this.value.WTFStringImpl.utf16Slice()[index], + .ZigString, .StaticZigString => if (!this.value.ZigString.is16Bit()) this.value.ZigString.slice()[index] else this.value.ZigString.utf16Slice()[index], else => 0, }; } @@ -1178,10 +1167,6 @@ pub const SliceWithUnderlyingString = struct { return this.utf8.slice(); } - pub fn sliceZ(this: SliceWithUnderlyingString) [:0]const u8 { - return this.utf8.sliceZ(); - } - pub fn format(self: SliceWithUnderlyingString, comptime fmt: []const u8, opts: std.fmt.FormatOptions, writer: anytype) !void { if (self.utf8.len == 0) { try self.underlying.format(fmt, opts, writer); diff --git a/test/integration/bun-types/fixture/yaml.ts b/test/integration/bun-types/fixture/yaml.ts index a362ba2af1..9b2090fd24 100644 --- a/test/integration/bun-types/fixture/yaml.ts +++ b/test/integration/bun-types/fixture/yaml.ts @@ -3,3 +3,8 @@ import { expectType } from "./utilities"; expectType(Bun.YAML.parse("")).is(); // @ts-expect-error expectType(Bun.YAML.parse({})).is(); +expectType(Bun.YAML.stringify({ abc: "def"})).is(); +// @ts-expect-error +expectType(Bun.YAML.stringify("hi", {})).is(); +// @ts-expect-error +expectType(Bun.YAML.stringify("hi", null, 123n)).is(); \ No newline at end of file diff --git a/test/js/bun/yaml/yaml.test.ts b/test/js/bun/yaml/yaml.test.ts index 9404ecbe53..1fd00527cd 100644 --- a/test/js/bun/yaml/yaml.test.ts +++ b/test/js/bun/yaml/yaml.test.ts @@ -1,53 +1,411 @@ +import { YAML } from "bun"; import { describe, expect, test } from "bun:test"; describe("Bun.YAML", () => { describe("parse", () => { + // Test various input types + describe("input types", () => { + test("parses from Buffer", () => { + const buffer = Buffer.from("key: value\nnumber: 42"); + expect(YAML.parse(buffer)).toEqual({ key: "value", number: 42 }); + }); + + test("parses from Buffer with UTF-8", () => { + const buffer = Buffer.from("emoji: 🎉\ntext: hello"); + expect(YAML.parse(buffer)).toEqual({ emoji: "🎉", text: "hello" }); + }); + + test("parses from ArrayBuffer", () => { + const str = "name: test\ncount: 3"; + const encoder = new TextEncoder(); + const arrayBuffer = encoder.encode(str).buffer; + expect(YAML.parse(arrayBuffer)).toEqual({ name: "test", count: 3 }); + }); + + test("parses from Uint8Array", () => { + const str = "- item1\n- item2\n- item3"; + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(str); + expect(YAML.parse(uint8Array)).toEqual(["item1", "item2", "item3"]); + }); + + test("parses from Uint16Array", () => { + const str = "foo: bar"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Create Uint16Array from the bytes + const uint16Array = new Uint16Array(bytes.buffer.slice(0, bytes.length)); + expect(YAML.parse(uint16Array)).toEqual({ foo: "bar" }); + }); + + test("parses from Int8Array", () => { + const str = "enabled: true\ncount: -5"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const int8Array = new Int8Array(bytes.buffer); + expect(YAML.parse(int8Array)).toEqual({ enabled: true, count: -5 }); + }); + + test("parses from Int16Array", () => { + const str = "status: ok"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for Int16Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 2) * 2); + new Uint8Array(alignedBuffer).set(bytes); + const int16Array = new Int16Array(alignedBuffer); + expect(YAML.parse(int16Array)).toEqual({ status: "ok" }); + }); + + test("parses from Int32Array", () => { + const str = "value: 42"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for Int32Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 4) * 4); + new Uint8Array(alignedBuffer).set(bytes); + const int32Array = new Int32Array(alignedBuffer); + expect(YAML.parse(int32Array)).toEqual({ value: 42 }); + }); + + test("parses from Uint32Array", () => { + const str = "test: pass"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for Uint32Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 4) * 4); + new Uint8Array(alignedBuffer).set(bytes); + const uint32Array = new Uint32Array(alignedBuffer); + expect(YAML.parse(uint32Array)).toEqual({ test: "pass" }); + }); + + test("parses from Float32Array", () => { + const str = "pi: 3.14"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for Float32Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 4) * 4); + new Uint8Array(alignedBuffer).set(bytes); + const float32Array = new Float32Array(alignedBuffer); + expect(YAML.parse(float32Array)).toEqual({ pi: 3.14 }); + }); + + test("parses from Float64Array", () => { + const str = "e: 2.718"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for Float64Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 8) * 8); + new Uint8Array(alignedBuffer).set(bytes); + const float64Array = new Float64Array(alignedBuffer); + expect(YAML.parse(float64Array)).toEqual({ e: 2.718 }); + }); + + test("parses from BigInt64Array", () => { + const str = "big: 999"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for BigInt64Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 8) * 8); + new Uint8Array(alignedBuffer).set(bytes); + const bigInt64Array = new BigInt64Array(alignedBuffer); + expect(YAML.parse(bigInt64Array)).toEqual({ big: 999 }); + }); + + test("parses from BigUint64Array", () => { + const str = "huge: 1000"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Ensure buffer is aligned for BigUint64Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 8) * 8); + new Uint8Array(alignedBuffer).set(bytes); + const bigUint64Array = new BigUint64Array(alignedBuffer); + expect(YAML.parse(bigUint64Array)).toEqual({ huge: 1000 }); + }); + + test("parses from DataView", () => { + const str = "test: value\nnum: 123"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const dataView = new DataView(bytes.buffer); + expect(YAML.parse(dataView)).toEqual({ test: "value", num: 123 }); + }); + + test("parses from Blob", async () => { + const blob = new Blob(["key1: value1\nkey2: value2"], { type: "text/yaml" }); + expect(YAML.parse(blob)).toEqual({ key1: "value1", key2: "value2" }); + }); + + test("parses from Blob with multiple parts", async () => { + const blob = new Blob(["users:\n", " - name: Alice\n", " - name: Bob"], { type: "text/yaml" }); + expect(YAML.parse(blob)).toEqual({ + users: [{ name: "Alice" }, { name: "Bob" }], + }); + }); + + test("parses complex YAML from Buffer", () => { + const yaml = ` +database: + host: localhost + port: 5432 + credentials: + username: admin + password: secret +`; + const buffer = Buffer.from(yaml); + expect(YAML.parse(buffer)).toEqual({ + database: { + host: "localhost", + port: 5432, + credentials: { + username: "admin", + password: "secret", + }, + }, + }); + }); + + test("parses arrays from TypedArray", () => { + const yaml = "[1, 2, 3, 4, 5]"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(yaml); + // Ensure buffer is aligned for Uint32Array + const alignedBuffer = new ArrayBuffer(Math.ceil(bytes.length / 4) * 4); + new Uint8Array(alignedBuffer).set(bytes); + const uint32Array = new Uint32Array(alignedBuffer); + expect(YAML.parse(uint32Array)).toEqual([1, 2, 3, 4, 5]); + }); + + test("handles empty Buffer", () => { + const buffer = Buffer.from(""); + expect(YAML.parse(buffer)).toBe(null); + }); + + test("handles empty ArrayBuffer", () => { + const arrayBuffer = new ArrayBuffer(0); + expect(YAML.parse(arrayBuffer)).toBe(null); + }); + + test("handles empty Blob", () => { + const blob = new Blob([]); + expect(YAML.parse(blob)).toBe(null); + }); + + test("parses multiline strings from Buffer", () => { + const yaml = ` +message: | + This is a + multiline + string +`; + const buffer = Buffer.from(yaml); + expect(YAML.parse(buffer)).toEqual({ + message: "This is a\nmultiline\nstring\n", + }); + }); + + test("handles invalid YAML in Buffer", () => { + const buffer = Buffer.from("{ invalid: yaml:"); + expect(() => YAML.parse(buffer)).toThrow(); + }); + + test("handles invalid YAML in ArrayBuffer", () => { + const encoder = new TextEncoder(); + const arrayBuffer = encoder.encode("[ unclosed").buffer; + expect(() => YAML.parse(arrayBuffer)).toThrow(); + }); + + test("parses with anchors and aliases from Buffer", () => { + const yaml = ` +defaults: &defaults + adapter: postgres + host: localhost +development: + <<: *defaults + database: dev_db +`; + const buffer = Buffer.from(yaml); + expect(YAML.parse(buffer)).toEqual({ + defaults: { + adapter: "postgres", + host: "localhost", + }, + development: { + adapter: "postgres", + host: "localhost", + database: "dev_db", + }, + }); + }); + + test("round-trip with Buffer", () => { + const obj = { + name: "test", + items: [1, 2, 3], + nested: { key: "value" }, + }; + const yamlStr = YAML.stringify(obj); + const buffer = Buffer.from(yamlStr); + expect(YAML.parse(buffer)).toEqual(obj); + }); + + test("round-trip with ArrayBuffer", () => { + const data = { + users: ["Alice", "Bob"], + settings: { theme: "dark", notifications: true }, + }; + const yamlStr = YAML.stringify(data); + const encoder = new TextEncoder(); + const arrayBuffer = encoder.encode(yamlStr).buffer; + expect(YAML.parse(arrayBuffer)).toEqual(data); + }); + + test("handles Buffer with offset", () => { + // Create a larger buffer and use a slice of it + const fullBuffer = Buffer.from("garbage_datakey: value\nmore_garbage"); + const slicedBuffer = fullBuffer.slice(12, 22); // "key: value" + expect(YAML.parse(slicedBuffer)).toEqual({ key: "value" }); + }); + + test("handles TypedArray with offset", () => { + const str = "name: test\ncount: 5"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + // Create a larger buffer with padding + const largerBuffer = new ArrayBuffer(bytes.length + 20); + const uint8View = new Uint8Array(largerBuffer); + // Put some garbage data before + uint8View.set(encoder.encode("garbage"), 0); + // Put our actual YAML data at offset 10 + uint8View.set(bytes, 10); + // Create a view that points to just our YAML data + const view = new Uint8Array(largerBuffer, 10, bytes.length); + expect(YAML.parse(view)).toEqual({ name: "test", count: 5 }); + }); + + // Test SharedArrayBuffer if available + if (typeof SharedArrayBuffer !== "undefined") { + test("parses from SharedArrayBuffer", () => { + const str = "shared: data"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const sharedBuffer = new SharedArrayBuffer(bytes.length); + new Uint8Array(sharedBuffer).set(bytes); + expect(YAML.parse(sharedBuffer)).toEqual({ shared: "data" }); + }); + + test("parses from TypedArray backed by SharedArrayBuffer", () => { + const str = "type: shared\nvalue: 123"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + const sharedBuffer = new SharedArrayBuffer(bytes.length); + const sharedArray = new Uint8Array(sharedBuffer); + sharedArray.set(bytes); + expect(YAML.parse(sharedArray)).toEqual({ type: "shared", value: 123 }); + }); + } + + test("handles File (which is a Blob)", () => { + const file = new File(["file:\n name: test.yaml\n size: 100"], "test.yaml", { type: "text/yaml" }); + expect(YAML.parse(file)).toEqual({ + file: { + name: "test.yaml", + size: 100, + }, + }); + }); + + test("complex nested structure from various input types", () => { + const complexYaml = ` +version: "1.0" +services: + web: + image: nginx:latest + ports: + - 80 + - 443 + db: + image: postgres:13 + environment: + POSTGRES_PASSWORD: secret +`; + + // Test with Buffer + const buffer = Buffer.from(complexYaml); + const expected = { + version: "1.0", + services: { + web: { + image: "nginx:latest", + ports: [80, 443], + }, + db: { + image: "postgres:13", + environment: { + POSTGRES_PASSWORD: "secret", + }, + }, + }, + }; + expect(YAML.parse(buffer)).toEqual(expected); + + // Test with ArrayBuffer + const encoder = new TextEncoder(); + const arrayBuffer = encoder.encode(complexYaml).buffer; + expect(YAML.parse(arrayBuffer)).toEqual(expected); + + // Test with Blob + const blob = new Blob([complexYaml]); + expect(YAML.parse(blob)).toEqual(expected); + }); + }); + test("parses null values", () => { - expect(Bun.YAML.parse("null")).toBe(null); - expect(Bun.YAML.parse("~")).toBe(null); - expect(Bun.YAML.parse("")).toBe(null); + expect(YAML.parse("null")).toBe(null); + expect(YAML.parse("~")).toBe(null); + expect(YAML.parse("")).toBe(null); }); test("parses boolean values", () => { - expect(Bun.YAML.parse("true")).toBe(true); - expect(Bun.YAML.parse("false")).toBe(false); - expect(Bun.YAML.parse("yes")).toBe(true); - expect(Bun.YAML.parse("no")).toBe(false); - expect(Bun.YAML.parse("on")).toBe(true); - expect(Bun.YAML.parse("off")).toBe(false); + expect(YAML.parse("true")).toBe(true); + expect(YAML.parse("false")).toBe(false); + expect(YAML.parse("yes")).toBe(true); + expect(YAML.parse("no")).toBe(false); + expect(YAML.parse("on")).toBe(true); + expect(YAML.parse("off")).toBe(false); }); test("parses number values", () => { - expect(Bun.YAML.parse("42")).toBe(42); - expect(Bun.YAML.parse("3.14")).toBe(3.14); - expect(Bun.YAML.parse("-17")).toBe(-17); - expect(Bun.YAML.parse("0")).toBe(0); - expect(Bun.YAML.parse(".inf")).toBe(Infinity); - expect(Bun.YAML.parse("-.inf")).toBe(-Infinity); - expect(Bun.YAML.parse(".nan")).toBeNaN(); + expect(YAML.parse("42")).toBe(42); + expect(YAML.parse("3.14")).toBe(3.14); + expect(YAML.parse("-17")).toBe(-17); + expect(YAML.parse("0")).toBe(0); + expect(YAML.parse(".inf")).toBe(Infinity); + expect(YAML.parse("-.inf")).toBe(-Infinity); + expect(YAML.parse(".nan")).toBeNaN(); }); test("parses string values", () => { - expect(Bun.YAML.parse('"hello world"')).toBe("hello world"); - expect(Bun.YAML.parse("'single quoted'")).toBe("single quoted"); - expect(Bun.YAML.parse("unquoted string")).toBe("unquoted string"); - expect(Bun.YAML.parse('key: "value with spaces"')).toEqual({ + expect(YAML.parse('"hello world"')).toBe("hello world"); + expect(YAML.parse("'single quoted'")).toBe("single quoted"); + expect(YAML.parse("unquoted string")).toBe("unquoted string"); + expect(YAML.parse('key: "value with spaces"')).toEqual({ key: "value with spaces", }); }); test("parses arrays", () => { - expect(Bun.YAML.parse("[1, 2, 3]")).toEqual([1, 2, 3]); - expect(Bun.YAML.parse("- 1\n- 2\n- 3")).toEqual([1, 2, 3]); - expect(Bun.YAML.parse("- a\n- b\n- c")).toEqual(["a", "b", "c"]); - expect(Bun.YAML.parse("[]")).toEqual([]); + expect(YAML.parse("[1, 2, 3]")).toEqual([1, 2, 3]); + expect(YAML.parse("- 1\n- 2\n- 3")).toEqual([1, 2, 3]); + expect(YAML.parse("- a\n- b\n- c")).toEqual(["a", "b", "c"]); + expect(YAML.parse("[]")).toEqual([]); }); test("parses objects", () => { - expect(Bun.YAML.parse("{a: 1, b: 2}")).toEqual({ a: 1, b: 2 }); - expect(Bun.YAML.parse("a: 1\nb: 2")).toEqual({ a: 1, b: 2 }); - expect(Bun.YAML.parse("{}")).toEqual({}); - expect(Bun.YAML.parse('name: "John"\nage: 30')).toEqual({ + expect(YAML.parse("{a: 1, b: 2}")).toEqual({ a: 1, b: 2 }); + expect(YAML.parse("a: 1\nb: 2")).toEqual({ a: 1, b: 2 }); + expect(YAML.parse("{}")).toEqual({}); + expect(YAML.parse('name: "John"\nage: 30')).toEqual({ name: "John", age: 30, }); @@ -67,7 +425,7 @@ users: - gaming - cooking `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ users: [ { name: "Alice", @@ -95,7 +453,7 @@ database: ssl: true timeout: 30 `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ database: { host: "localhost", port: 5432, @@ -119,7 +477,7 @@ parent: &ref name: child parent: *ref `; - const result = Bun.YAML.parse(yaml); + const result = YAML.parse(yaml); expect(result.parent.name).toBe("parent"); expect(result.parent.child.name).toBe("child"); expect(result.parent.child.parent).toBe(result.parent); @@ -132,7 +490,7 @@ document: 1 --- document: 2 `; - expect(Bun.YAML.parse(yaml)).toEqual([{ document: 1 }, { document: 2 }]); + expect(YAML.parse(yaml)).toEqual([{ document: 1 }, { document: 2 }]); }); test("handles multiline strings", () => { @@ -146,7 +504,7 @@ folded: > a multiline string `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ literal: "This is a\nmultiline\nstring\n", folded: "This is also a multiline string\n", }); @@ -158,7 +516,7 @@ folded: > 'another.key': value2 123: numeric-key `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ "special-key": "value1", "another.key": "value2", "123": "numeric-key", @@ -172,7 +530,7 @@ empty_array: [] empty_object: {} null_value: null `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ empty_string: "", empty_array: [], empty_object: {}, @@ -181,9 +539,9 @@ null_value: null }); test("throws on invalid YAML", () => { - expect(() => Bun.YAML.parse("[ invalid")).toThrow(); - expect(() => Bun.YAML.parse("{ key: value")).toThrow(); - expect(() => Bun.YAML.parse(":\n : - invalid")).toThrow(); + expect(() => YAML.parse("[ invalid")).toThrow(); + expect(() => YAML.parse("{ key: value")).toThrow(); + expect(() => YAML.parse(":\n : - invalid")).toThrow(); }); test("handles dates and timestamps", () => { @@ -191,7 +549,7 @@ null_value: null date: 2024-01-15 timestamp: 2024-01-15T10:30:00Z `; - const result = Bun.YAML.parse(yaml); + const result = YAML.parse(yaml); // Dates might be parsed as strings or Date objects depending on implementation expect(result.date).toBeDefined(); expect(result.timestamp).toBeDefined(); @@ -213,7 +571,7 @@ assignments: project2: - *user2 `; - const result = Bun.YAML.parse(yaml); + const result = YAML.parse(yaml); expect(result.assignments.project1[0]).toBe(result.definitions[0]); expect(result.assignments.project1[1]).toBe(result.definitions[1]); expect(result.assignments.project2[0]).toBe(result.definitions[1]); @@ -226,7 +584,7 @@ key: value # inline comment # Another comment another: value `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ key: "value", another: "value", }); @@ -243,7 +601,7 @@ block: key1: value1 key2: value2 `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ array: [1, 2, 3], object: { a: 1, b: 2 }, mixed: [ @@ -263,7 +621,7 @@ single: 'This is a ''quoted'' string' double: "Line 1\\nLine 2\\tTabbed" unicode: "\\u0041\\u0042\\u0043" `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ single: "This is a 'quoted' string", double: "Line 1\nLine 2\tTabbed", unicode: "ABC", @@ -278,7 +636,7 @@ hex: 0xFF octal: 0o777 binary: 0b1010 `; - const result = Bun.YAML.parse(yaml); + const result = YAML.parse(yaml); expect(result.int).toBe(9007199254740991); expect(result.float).toBe(1.7976931348623157e308); expect(result.hex).toBe(255); @@ -294,7 +652,7 @@ explicit_float: !!float "3.14" explicit_bool: !!bool "yes" explicit_null: !!null "anything" `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ explicit_string: "123", explicit_int: "456", explicit_float: "3.14", @@ -308,7 +666,7 @@ explicit_null: !!null "anything" shasum1: 1e18495d9d7f6b41135e5ee828ef538dc94f9be4 shasum2: 19f3afed71c8ee421de3892615197b57bd0f2c8f `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ shasum1: "1e18495d9d7f6b41135e5ee828ef538dc94f9be4", shasum2: "19f3afed71c8ee421de3892615197b57bd0f2c8f", }); @@ -327,7 +685,7 @@ production: database: prod_db host: prod.example.com `; - expect(Bun.YAML.parse(yaml)).toEqual({ + expect(YAML.parse(yaml)).toEqual({ defaults: { adapter: "postgres", host: "localhost", @@ -345,4 +703,1548 @@ production: }); }); }); + + describe("stringify", () => { + // Basic data type tests + test("stringifies null", () => { + expect(YAML.stringify(null)).toBe("null"); + expect(YAML.stringify(undefined)).toBe(undefined); + }); + + test("stringifies booleans", () => { + expect(YAML.stringify(true)).toBe("true"); + expect(YAML.stringify(false)).toBe("false"); + }); + + test("stringifies numbers", () => { + expect(YAML.stringify(42)).toBe("42"); + expect(YAML.stringify(3.14)).toBe("3.14"); + expect(YAML.stringify(-17)).toBe("-17"); + expect(YAML.stringify(0)).toBe("0"); + expect(YAML.stringify(-0)).toBe("-0"); + expect(YAML.stringify(Infinity)).toBe(".inf"); + expect(YAML.stringify(-Infinity)).toBe("-.inf"); + expect(YAML.stringify(NaN)).toBe(".nan"); + }); + + test("stringifies strings", () => { + expect(YAML.stringify("hello")).toBe("hello"); + expect(YAML.stringify("hello world")).toBe("hello world"); + expect(YAML.stringify("")).toBe('""'); + expect(YAML.stringify("true")).toBe('"true"'); // Keywords need quoting + expect(YAML.stringify("false")).toBe('"false"'); + expect(YAML.stringify("null")).toBe('"null"'); + expect(YAML.stringify("123")).toBe('"123"'); // Numbers need quoting + }); + + test("stringifies strings with special characters", () => { + expect(YAML.stringify("line1\nline2")).toBe('"line1\\nline2"'); + expect(YAML.stringify('with "quotes"')).toBe('"with \\"quotes\\""'); + expect(YAML.stringify("with\ttab")).toBe('"with\\ttab"'); + expect(YAML.stringify("with\rcarriage")).toBe('"with\\rcarriage"'); + expect(YAML.stringify("with\x00null")).toBe('"with\\0null"'); + }); + + test("stringifies strings that need quoting", () => { + expect(YAML.stringify("&anchor")).toBe('"&anchor"'); + expect(YAML.stringify("*alias")).toBe('"*alias"'); + expect(YAML.stringify("#comment")).toBe('"#comment"'); + expect(YAML.stringify("---")).toBe('"---"'); + expect(YAML.stringify("...")).toBe('"..."'); + expect(YAML.stringify("{flow}")).toBe('"{flow}"'); + expect(YAML.stringify("[flow]")).toBe('"[flow]"'); + expect(YAML.stringify("key: value")).toBe('"key: value"'); + expect(YAML.stringify(" leading space")).toBe('" leading space"'); + expect(YAML.stringify("trailing space ")).toBe('"trailing space "'); + }); + + test("stringifies empty arrays", () => { + expect(YAML.stringify([])).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"); + expect(YAML.stringify([true, false, null], null, 2)).toBe("- true\n- false\n- null"); + }); + + test("stringifies nested arrays", () => { + expect( + YAML.stringify( + [ + [1, 2], + [3, 4], + ], + null, + 2, + ), + ).toBe("- - 1\n - 2\n- - 3\n - 4"); + expect(YAML.stringify([1, [2, 3], 4], null, 2)).toBe("- 1\n- - 2\n - 3\n- 4"); + }); + + test("stringifies empty objects", () => { + expect(YAML.stringify({})).toBe("{}"); + }); + + test("stringifies simple objects", () => { + expect(YAML.stringify({ a: 1, b: 2 }, null, 2)).toBe("a: 1\nb: 2"); + expect(YAML.stringify({ name: "John", age: 30 }, null, 2)).toBe("name: John\nage: 30"); + expect(YAML.stringify({ flag: true, value: null }, null, 2)).toBe("flag: true\nvalue: null"); + }); + + test("stringifies nested objects", () => { + const obj = { + database: { + host: "localhost", + port: 5432, + }, + }; + expect(YAML.stringify(obj, null, 2)).toBe("database: \n host: localhost\n port: 5432"); + }); + + test("stringifies mixed structures", () => { + const obj = { + users: [ + { name: "Alice", hobbies: ["reading", "hiking"] }, + { name: "Bob", hobbies: ["gaming"] }, + ], + }; + const expected = + "users: \n - name: Alice\n hobbies: \n - reading\n - hiking\n - name: Bob\n hobbies: \n - gaming"; + expect(YAML.stringify(obj, null, 2)).toBe(expected); + }); + + test("stringifies objects with special keys", () => { + expect(YAML.stringify({ "special-key": "value" }, null, 2)).toBe("special-key: value"); + expect(YAML.stringify({ "123": "numeric" }, null, 2)).toBe('"123": numeric'); + expect(YAML.stringify({ "": "empty" }, null, 2)).toBe('"": empty'); + expect(YAML.stringify({ "true": "keyword" }, null, 2)).toBe('"true": keyword'); + }); + + // Error case tests + test("throws on BigInt", () => { + expect(() => YAML.stringify(BigInt(123))).toThrow("YAML.stringify cannot serialize BigInt"); + }); + + test("throws on symbols", () => { + expect(YAML.stringify(Symbol("test"))).toBe(undefined); + }); + + test("throws on replacer parameter", () => { + expect(() => YAML.stringify({ a: 1 }, () => {})).toThrow("YAML.stringify does not support the replacer argument"); + }); + + test("handles functions", () => { + // Functions get stringified as empty objects + expect(YAML.stringify(() => {})).toBe(undefined); + expect(YAML.stringify({ fn: () => {}, value: 42 }, null, 2)).toBe("value: 42"); + }); + + // Round-trip tests + describe("round-trip compatibility", () => { + test("round-trips null values", () => { + expect(YAML.parse(YAML.stringify(null))).toBe(null); + }); + + test("round-trips boolean values", () => { + expect(YAML.parse(YAML.stringify(true))).toBe(true); + expect(YAML.parse(YAML.stringify(false))).toBe(false); + }); + + test("round-trips number values", () => { + expect(YAML.parse(YAML.stringify(42))).toBe(42); + expect(YAML.parse(YAML.stringify(3.14))).toBe(3.14); + expect(YAML.parse(YAML.stringify(-17))).toBe(-17); + expect(YAML.parse(YAML.stringify(0))).toBe(0); + expect(YAML.parse(YAML.stringify(-0))).toBe(-0); + expect(YAML.parse(YAML.stringify(Infinity))).toBe(Infinity); + expect(YAML.parse(YAML.stringify(-Infinity))).toBe(-Infinity); + expect(YAML.parse(YAML.stringify(NaN))).toBeNaN(); + }); + + test("round-trips string values", () => { + expect(YAML.parse(YAML.stringify("hello"))).toBe("hello"); + expect(YAML.parse(YAML.stringify("hello world"))).toBe("hello world"); + expect(YAML.parse(YAML.stringify(""))).toBe(""); + expect(YAML.parse(YAML.stringify("true"))).toBe("true"); + expect(YAML.parse(YAML.stringify("123"))).toBe("123"); + }); + + test("round-trips strings with special characters", () => { + expect(YAML.parse(YAML.stringify("line1\nline2"))).toBe("line1\nline2"); + expect(YAML.parse(YAML.stringify('with "quotes"'))).toBe('with "quotes"'); + expect(YAML.parse(YAML.stringify("with\ttab"))).toBe("with\ttab"); + expect(YAML.parse(YAML.stringify("with\rcarriage"))).toBe("with\rcarriage"); + }); + + test("round-trips arrays", () => { + expect(YAML.parse(YAML.stringify([]))).toEqual([]); + expect(YAML.parse(YAML.stringify([1, 2, 3]))).toEqual([1, 2, 3]); + expect(YAML.parse(YAML.stringify(["a", "b", "c"]))).toEqual(["a", "b", "c"]); + expect(YAML.parse(YAML.stringify([true, false, null]))).toEqual([true, false, null]); + }); + + test("round-trips nested arrays", () => { + expect( + YAML.parse( + YAML.stringify([ + [1, 2], + [3, 4], + ]), + ), + ).toEqual([ + [1, 2], + [3, 4], + ]); + expect(YAML.parse(YAML.stringify([1, [2, 3], 4]))).toEqual([1, [2, 3], 4]); + }); + + test("round-trips objects", () => { + expect(YAML.parse(YAML.stringify({}))).toEqual({}); + expect(YAML.parse(YAML.stringify({ a: 1, b: 2 }))).toEqual({ a: 1, b: 2 }); + expect(YAML.parse(YAML.stringify({ name: "John", age: 30 }))).toEqual({ name: "John", age: 30 }); + }); + + test("round-trips nested objects", () => { + const obj = { + database: { + host: "localhost", + port: 5432, + credentials: { + username: "admin", + password: "secret", + }, + }, + }; + expect(YAML.parse(YAML.stringify(obj))).toEqual(obj); + }); + + test("round-trips mixed structures", () => { + const obj = { + users: [ + { name: "Alice", age: 30, hobbies: ["reading", "hiking"] }, + { name: "Bob", age: 25, hobbies: ["gaming", "cooking"] }, + ], + config: { + debug: true, + timeout: 5000, + }, + }; + expect(YAML.parse(YAML.stringify(obj))).toEqual(obj); + }); + + test("round-trips objects with special keys", () => { + const obj = { + "special-key": "value1", + "123": "numeric-key", + "true": "keyword-key", + "": "empty-key", + }; + expect(YAML.parse(YAML.stringify(obj))).toEqual(obj); + }); + + test("round-trips arrays with mixed types", () => { + const arr = ["string", 42, true, null, { nested: "object" }, [1, 2, 3]]; + expect(YAML.parse(YAML.stringify(arr))).toEqual(arr); + }); + + test("round-trips complex real-world structures", () => { + const config = { + version: "1.0", + services: { + web: { + image: "nginx:latest", + ports: ["80:80", "443:443"], + environment: { + NODE_ENV: "production", + DEBUG: false, + }, + }, + db: { + image: "postgres:13", + environment: { + POSTGRES_PASSWORD: "secret", + POSTGRES_DB: "myapp", + }, + volumes: ["./data:/var/lib/postgresql/data"], + }, + }, + networks: { + default: { + driver: "bridge", + }, + }, + }; + expect(YAML.parse(YAML.stringify(config))).toEqual(config); + }); + }); + + test("strings are properly referenced", () => { + const config = { + version: "1.0", + services: { + web: { + image: "nginx:latest", + ports: ["80:80", "443:443"], + environment: { + NODE_ENV: "production", + DEBUG: false, + }, + }, + db: { + image: "postgres:13", + environment: { + POSTGRES_PASSWORD: "secret", + POSTGRES_DB: "myapp", + }, + volumes: ["./data:/var/lib/postgresql/data"], + }, + }, + networks: { + default: { + driver: "bridge", + }, + }, + }; + + for (let i = 0; i < 10000; i++) { + expect(YAML.stringify(config)).toBeString(); + } + }); + + // Anchor and alias tests (reference handling) + describe("reference handling", () => { + test("handles object references with anchors and aliases", () => { + const shared = { shared: "value" }; + const obj = { + first: shared, + second: shared, + }; + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + // Should preserve object identity + expect(parsed.first).toBe(parsed.second); + expect(parsed.first.shared).toBe("value"); + }); + + test("handles array references with anchors and aliases", () => { + const sharedArray = [1, 2, 3]; + const obj = { + arrays: [sharedArray, sharedArray], + }; + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + // Should preserve array identity + expect(parsed.arrays[0]).toBe(parsed.arrays[1]); + expect(parsed.arrays[0]).toEqual([1, 2, 3]); + }); + + test("handles deeply nested references", () => { + const sharedConfig = { host: "localhost", port: 5432 }; + const obj = { + development: { + database: sharedConfig, + }, + test: { + database: sharedConfig, + }, + shared: sharedConfig, + }; + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + expect(parsed.development.database).toBe(parsed.test.database); + expect(parsed.development.database).toBe(parsed.shared); + expect(parsed.shared.host).toBe("localhost"); + }); + + test.todo("handles self-referencing objects", () => { + // Skipping as this causes build issues with circular references + const obj = { name: "root" }; + obj.self = obj; + + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + expect(parsed.self).toBe(parsed); + expect(parsed.name).toBe("root"); + }); + + test("generates unique anchor names for different objects", () => { + const obj1 = { type: "first" }; + const obj2 = { type: "second" }; + const container = { + a: obj1, + b: obj1, + c: obj2, + d: obj2, + }; + + const yaml = YAML.stringify(container); + const parsed = YAML.parse(yaml); + + expect(parsed.a).toBe(parsed.b); + expect(parsed.c).toBe(parsed.d); + expect(parsed.a).not.toBe(parsed.c); + expect(parsed.a.type).toBe("first"); + expect(parsed.c.type).toBe("second"); + }); + }); + + // Edge cases and error handling + describe("edge cases", () => { + test("handles very deep nesting", () => { + let deep = {}; + let current = deep; + for (let i = 0; i < 100; i++) { + current.next = { level: i }; + current = current.next; + } + + const yaml = YAML.stringify(deep); + const parsed = YAML.parse(yaml); + + expect(parsed.next.next.next.level).toBe(2); + }); + + // Test strings that need quoting due to YAML keywords + test("quotes YAML boolean keywords", () => { + // All variations of true/false keywords + expect(YAML.stringify("True")).toBe('"True"'); + expect(YAML.stringify("TRUE")).toBe('"TRUE"'); + expect(YAML.stringify("False")).toBe('"False"'); + expect(YAML.stringify("FALSE")).toBe('"FALSE"'); + expect(YAML.stringify("yes")).toBe('"yes"'); + expect(YAML.stringify("Yes")).toBe('"Yes"'); + expect(YAML.stringify("YES")).toBe('"YES"'); + expect(YAML.stringify("no")).toBe('"no"'); + expect(YAML.stringify("No")).toBe('"No"'); + expect(YAML.stringify("NO")).toBe('"NO"'); + expect(YAML.stringify("on")).toBe('"on"'); + expect(YAML.stringify("On")).toBe('"On"'); + expect(YAML.stringify("ON")).toBe('"ON"'); + expect(YAML.stringify("off")).toBe('"off"'); + expect(YAML.stringify("Off")).toBe('"Off"'); + expect(YAML.stringify("OFF")).toBe('"OFF"'); + // Single letter booleans + expect(YAML.stringify("n")).toBe('"n"'); + expect(YAML.stringify("N")).toBe('"N"'); + expect(YAML.stringify("y")).toBe('"y"'); + expect(YAML.stringify("Y")).toBe('"Y"'); + }); + + test("quotes YAML null keywords", () => { + expect(YAML.stringify("Null")).toBe('"Null"'); + expect(YAML.stringify("NULL")).toBe('"NULL"'); + expect(YAML.stringify("~")).toBe('"~"'); + }); + + test("quotes YAML infinity and NaN keywords", () => { + expect(YAML.stringify(".inf")).toBe('".inf"'); + expect(YAML.stringify(".Inf")).toBe('".Inf"'); + expect(YAML.stringify(".INF")).toBe('".INF"'); + expect(YAML.stringify(".nan")).toBe('".nan"'); + expect(YAML.stringify(".NaN")).toBe('".NaN"'); + expect(YAML.stringify(".NAN")).toBe('".NAN"'); + }); + + test("quotes strings starting with special indicators", () => { + expect(YAML.stringify("?question")).toBe('"?question"'); + expect(YAML.stringify("|literal")).toBe('"|literal"'); + expect(YAML.stringify("-dash")).toBe('"-dash"'); + expect(YAML.stringify("greater")).toBe('">greater"'); + expect(YAML.stringify("!exclaim")).toBe('"!exclaim"'); + expect(YAML.stringify("%percent")).toBe('"%percent"'); + expect(YAML.stringify("@at")).toBe('"@at"'); + }); + + test("quotes strings that look like numbers", () => { + // Decimal numbers + expect(YAML.stringify("42")).toBe('"42"'); + expect(YAML.stringify("3.14")).toBe('"3.14"'); + expect(YAML.stringify("-17")).toBe('"-17"'); + expect(YAML.stringify("+99")).toBe("+99"); // + at start doesn't force quotes + expect(YAML.stringify(".5")).toBe('".5"'); + expect(YAML.stringify("-.5")).toBe('"-.5"'); + + // Scientific notation + expect(YAML.stringify("1e10")).toBe('"1e10"'); + expect(YAML.stringify("1E10")).toBe('"1E10"'); + expect(YAML.stringify("1.5e-10")).toBe('"1.5e-10"'); + expect(YAML.stringify("3.14e+5")).toBe('"3.14e+5"'); + + // Hex numbers + expect(YAML.stringify("0x1F")).toBe('"0x1F"'); + expect(YAML.stringify("0xDEADBEEF")).toBe('"0xDEADBEEF"'); + expect(YAML.stringify("0XFF")).toBe('"0XFF"'); + + // Octal numbers + expect(YAML.stringify("0o777")).toBe('"0o777"'); + expect(YAML.stringify("0O644")).toBe('"0O644"'); + }); + + test("quotes strings with colons followed by spaces", () => { + expect(YAML.stringify("key: value")).toBe('"key: value"'); + expect(YAML.stringify("key:value")).toBe("key:value"); // no quote when no space + expect(YAML.stringify("http://example.com")).toBe("http://example.com"); // URLs shouldn't need quotes + + // These need quotes due to colon+space pattern + expect(YAML.stringify("desc: this is")).toBe('"desc: this is"'); + expect(YAML.stringify("label:\ttab")).toBe('"label:\\ttab"'); + expect(YAML.stringify("text:\n")).toBe('"text:\\n"'); + expect(YAML.stringify("item:\r")).toBe('"item:\\r"'); + }); + + test("quotes strings containing flow indicators", () => { + expect(YAML.stringify("{json}")).toBe('"{json}"'); + expect(YAML.stringify("[array]")).toBe('"[array]"'); + expect(YAML.stringify("a,b,c")).toBe('"a,b,c"'); + expect(YAML.stringify("mixed{flow")).toBe('"mixed{flow"'); + expect(YAML.stringify("mixed}flow")).toBe('"mixed}flow"'); + expect(YAML.stringify("mixed[flow")).toBe('"mixed[flow"'); + expect(YAML.stringify("mixed]flow")).toBe('"mixed]flow"'); + }); + + test("quotes strings with special single characters", () => { + expect(YAML.stringify("#")).toBe('"#"'); + expect(YAML.stringify("`")).toBe('"`"'); + expect(YAML.stringify("'")).toBe('"\'"'); + }); + + test("handles control characters and special escapes", () => { + // Basic control characters + expect(YAML.stringify("\x00")).toBe('"\\0"'); // null + expect(YAML.stringify("\x07")).toBe('"\\a"'); // bell + expect(YAML.stringify("\x08")).toBe('"\\b"'); // backspace + expect(YAML.stringify("\x09")).toBe('"\\t"'); // tab + expect(YAML.stringify("\x0a")).toBe('"\\n"'); // line feed + expect(YAML.stringify("\x0b")).toBe('"\\v"'); // vertical tab + expect(YAML.stringify("\x0c")).toBe('"\\f"'); // form feed + expect(YAML.stringify("\x0d")).toBe('"\\r"'); // carriage return + expect(YAML.stringify("\x1b")).toBe('"\\e"'); // escape + expect(YAML.stringify("\x22")).toBe('"\\\""'); // double quote + expect(YAML.stringify("\x5c")).toBe("\\"); // backslash - not quoted + + // Other control characters (hex notation) + expect(YAML.stringify("\x01")).toBe('"\\x01"'); + expect(YAML.stringify("\x02")).toBe('"\\x02"'); + expect(YAML.stringify("\x03")).toBe('"\\x03"'); + expect(YAML.stringify("\x04")).toBe('"\\x04"'); + expect(YAML.stringify("\x05")).toBe('"\\x05"'); + expect(YAML.stringify("\x06")).toBe('"\\x06"'); + expect(YAML.stringify("\x0e")).toBe('"\\x0e"'); + expect(YAML.stringify("\x0f")).toBe('"\\x0f"'); + expect(YAML.stringify("\x10")).toBe('"\\x10"'); + expect(YAML.stringify("\x7f")).toBe('"\\x7f"'); // delete + + // Unicode control characters + expect(YAML.stringify("\x85")).toBe('"\\N"'); // next line + expect(YAML.stringify("\xa0")).toBe('"\\_"'); // non-breaking space + + // Combined in strings + expect(YAML.stringify("hello\x00world")).toBe('"hello\\0world"'); + expect(YAML.stringify("line1\x0bline2")).toBe('"line1\\vline2"'); + expect(YAML.stringify("alert\x07sound")).toBe('"alert\\asound"'); + }); + + test("handles special number formats", () => { + // Positive zero + expect(YAML.stringify(+0)).toBe("0"); // +0 becomes just 0 + + // Round-trip special numbers + expect(YAML.parse(YAML.stringify(+0))).toBe(0); + expect(Object.is(YAML.parse(YAML.stringify(-0)), -0)).toBe(true); + }); + + test("quotes strings that would be ambiguous YAML", () => { + // Strings that look like YAML document markers + expect(YAML.stringify("---")).toBe('"---"'); + expect(YAML.stringify("...")).toBe('"..."'); + + // But these don't need quotes (not exactly three) + expect(YAML.stringify("--")).toBe('"--"'); // -- gets quoted + expect(YAML.stringify("----")).toBe('"----"'); + expect(YAML.stringify("..")).toBe(".."); + expect(YAML.stringify("....")).toBe("...."); + }); + + test("handles mixed content strings", () => { + // Strings with numbers and text (shouldn't be quoted unless they parse as numbers) + expect(YAML.stringify("abc123")).toBe("abc123"); + expect(YAML.stringify("123abc")).toBe("123abc"); + expect(YAML.stringify("1.2.3")).toBe("1.2.3"); + expect(YAML.stringify("v1.0.0")).toBe("v1.0.0"); + + // SHA-like strings that could be mistaken for scientific notation + expect(YAML.stringify("1e10abc")).toBe("1e10abc"); + expect(YAML.stringify("deadbeef")).toBe("deadbeef"); + expect(YAML.stringify("0xNotHex")).toBe("0xNotHex"); + }); + + test("handles whitespace edge cases", () => { + // Leading/trailing whitespace + expect(YAML.stringify(" leading")).toBe('" leading"'); + expect(YAML.stringify("trailing ")).toBe('"trailing "'); + expect(YAML.stringify("\tleading")).toBe('"\\tleading"'); + expect(YAML.stringify("trailing\t")).toBe('"trailing\\t"'); + expect(YAML.stringify("\nleading")).toBe('"\\nleading"'); + expect(YAML.stringify("trailing\n")).toBe('"trailing\\n"'); + expect(YAML.stringify("\rleading")).toBe('"\\rleading"'); + expect(YAML.stringify("trailing\r")).toBe('"trailing\\r"'); + + // Mixed internal content is okay + expect(YAML.stringify("no problem")).toBe("no problem"); + expect(YAML.stringify("internal\ttabs\tok")).toBe('"internal\\ttabs\\tok"'); + }); + + test("handles boxed primitives", () => { + // Boxed primitives should be unwrapped + const boxedNumber = new Number(42); + const boxedString = new String("hello"); + const boxedBoolean = new Boolean(true); + + expect(YAML.stringify(boxedNumber)).toBe("42"); + expect(YAML.stringify(boxedString)).toBe("hello"); + expect(YAML.stringify(boxedBoolean)).toBe("true"); + + // In objects + const obj = { + num: new Number(3.14), + str: new String("world"), + bool: new Boolean(false), + }; + expect(YAML.stringify(obj, null, 2)).toBe("num: \n 3.14\nstr: world\nbool: \n false"); + }); + + test("handles Date objects", () => { + // Date objects get converted to ISO string via toString() + const date = new Date("2024-01-15T10:30:00Z"); + const result = YAML.stringify(date); + // Dates become empty objects currently + expect(result).toBe("{}"); + + // In objects + const obj = { created: date }; + expect(YAML.stringify(obj, null, 2)).toBe("created: \n {}"); + }); + + test("handles RegExp objects", () => { + // RegExp objects become empty objects + const regex = /test/gi; + expect(YAML.stringify(regex)).toBe("{}"); + + const obj = { pattern: regex }; + expect(YAML.stringify(obj, null, 2)).toBe("pattern: \n {}"); + }); + + test("handles Error objects", () => { + // Error objects have enumerable properties + const error = new Error("Test error"); + const result = YAML.stringify(error); + expect(result).toBe("{}"); // Errors have no enumerable properties + + // Custom error with properties + const customError = new Error("Custom"); + customError.code = "ERR_TEST"; + customError.details = { line: 42 }; + const customResult = YAML.stringify(customError); + expect(customResult).toContain("code: ERR_TEST"); + expect(customResult).toContain("details:"); + expect(customResult).toContain("line: 42"); + }); + + test("handles Maps and Sets", () => { + // Maps become empty objects + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect(YAML.stringify(map)).toBe("{}"); + + // Sets become empty objects + const set = new Set([1, 2, 3]); + expect(YAML.stringify(set)).toBe("{}"); + }); + + test("handles property descriptors", () => { + // Non-enumerable properties should be skipped + const obj = {}; + Object.defineProperty(obj, "hidden", { + value: "secret", + enumerable: false, + }); + Object.defineProperty(obj, "visible", { + value: "public", + enumerable: true, + }); + + expect(YAML.stringify(obj, null, 2)).toBe("visible: public"); + }); + + test("handles getters", () => { + // Getters should be evaluated + const obj = { + get computed() { + return "computed value"; + }, + normal: "normal value", + }; + + const result = YAML.stringify(obj); + expect(result).toContain("computed: computed value"); + expect(result).toContain("normal: normal value"); + }); + + test("handles object with numeric string keys", () => { + // Keys that look like numbers but are strings + const obj = { + "0": "zero", + "1": "one", + "42": "answer", + "3.14": "pi", + "-1": "negative", + "1e10": "scientific", + }; + + const result = YAML.stringify(obj); + expect(result).toContain('"0": zero'); + expect(result).toContain('"1": one'); + expect(result).toContain('"42": answer'); + expect(result).toContain('"3.14": pi'); + expect(result).toContain('"-1": negative'); + expect(result).toContain('"1e10": scientific'); + }); + + test("handles complex anchor scenarios", () => { + // Multiple references to same empty object/array + const emptyObj = {}; + const emptyArr = []; + const container = { + obj1: emptyObj, + obj2: emptyObj, + arr1: emptyArr, + arr2: emptyArr, + }; + + const yaml = YAML.stringify(container); + const parsed = YAML.parse(yaml); + expect(parsed.obj1).toBe(parsed.obj2); + expect(parsed.arr1).toBe(parsed.arr2); + }); + + test("handles property names that need escaping", () => { + const obj = { + "": "empty key", + " ": "space key", + "\t": "tab key", + "\n": "newline key", + "null": "null key", + "true": "true key", + "123": "numeric key", + "#comment": "hash key", + "key:value": "colon key", + "key: value": "colon space key", + "[array]": "bracket key", + "{object}": "brace key", + }; + + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + expect(parsed[""]).toBe("empty key"); + expect(parsed[" "]).toBe("space key"); + expect(parsed["\t"]).toBe("tab key"); + expect(parsed["\n"]).toBe("newline key"); + expect(parsed["null"]).toBe("null key"); + expect(parsed["true"]).toBe("true key"); + expect(parsed["123"]).toBe("numeric key"); + expect(parsed["#comment"]).toBe("hash key"); + expect(parsed["key:value"]).toBe("colon key"); + expect(parsed["key: value"]).toBe("colon space key"); + expect(parsed["[array]"]).toBe("bracket key"); + expect(parsed["{object}"]).toBe("brace key"); + }); + + test("handles arrays with objects containing undefined/symbol", () => { + const arr = [{ a: 1, b: undefined, c: 2 }, { x: Symbol("test"), y: 3 }, { valid: "data" }]; + + const yaml = YAML.stringify(arr); + const parsed = YAML.parse(yaml); + + expect(parsed).toEqual([{ a: 1, c: 2 }, { y: 3 }, { valid: "data" }]); + }); + + test("handles stack overflow protection", () => { + // Create deeply nested structure approaching stack limit + let deep = {}; + let current = deep; + for (let i = 0; i < 1000000; i++) { + current.next = {}; + current = current.next; + } + + // Should throw stack overflow for deeply nested structures + expect(() => YAML.stringify(deep)).toThrow("Maximum call stack size exceeded"); + }); + + test("handles arrays as root with references", () => { + const shared = { shared: true }; + const arr = [shared, "middle", shared]; + + const yaml = YAML.stringify(arr); + const parsed = YAML.parse(yaml); + + expect(parsed[0]).toBe(parsed[2]); + expect(parsed[0].shared).toBe(true); + expect(parsed[1]).toBe("middle"); + }); + + test("handles mixed references in nested structures", () => { + const sharedData = { type: "shared" }; + const sharedArray = [1, 2, 3]; + + const complex = { + level1: { + data: sharedData, + items: sharedArray, + }, + level2: { + reference: sharedData, + moreItems: sharedArray, + nested: { + deepRef: sharedData, + }, + }, + }; + + const yaml = YAML.stringify(complex); + const parsed = YAML.parse(yaml); + + expect(parsed.level1.data).toBe(parsed.level2.reference); + expect(parsed.level1.data).toBe(parsed.level2.nested.deepRef); + expect(parsed.level1.items).toBe(parsed.level2.moreItems); + }); + + test("handles anchor name conflicts with property names", () => { + // Test 1: Object used as property value with same name conflicts + const sharedObj = { value: "shared" }; + const obj1 = { + data: sharedObj, + nested: { + data: sharedObj, // Same property name "data" + }, + }; + + const yaml1 = YAML.stringify(obj1, null, 2); + expect(yaml1).toMatchInlineSnapshot(` +"data: + &data + value: shared +nested: + data: + *data" +`); + + // Test 2: Multiple objects with same property names needing counters + const obj2Shared = { type: "A" }; + const obj3Shared = { type: "B" }; + const obj4Shared = { type: "C" }; + + const obj2 = { + item: obj2Shared, + nested1: { + item: obj2Shared, // second use, will be alias + other: { + item: obj3Shared, // different object, needs &item1 + }, + }, + nested2: { + item: obj3Shared, // alias to &item1 + sub: { + item: obj4Shared, // another different object, needs &item2 + }, + }, + refs: { + item: obj4Shared, // alias to &item2 + }, + }; + + const yaml2 = YAML.stringify(obj2, null, 2); + expect(yaml2).toMatchInlineSnapshot(` +"item: + &item + type: A +nested1: + item: + *item + other: + item: + &item1 + type: B +nested2: + item: + *item1 + sub: + item: + &item2 + type: C +refs: + item: + *item2" +`); + + const parsed2 = YAML.parse(yaml2); + expect(parsed2.item).toBe(parsed2.nested1.item); + expect(parsed2.nested1.other.item).toBe(parsed2.nested2.item); + expect(parsed2.nested2.sub.item).toBe(parsed2.refs.item); + expect(parsed2.item.type).toBe("A"); + expect(parsed2.nested1.other.item.type).toBe("B"); + expect(parsed2.nested2.sub.item.type).toBe("C"); + }); + + test("handles array item anchor counter increments", () => { + // Test 1: Multiple array items that are objects need incrementing counters + const sharedA = { id: "A" }; + const sharedB = { id: "B" }; + const sharedC = { id: "C" }; + + const arr1 = [ + sharedA, // Gets &item0 + sharedA, // Gets *item0 + sharedB, // Gets &item1 + sharedC, // Gets &item2 + sharedB, // Gets *item1 + sharedC, // Gets *item2 + ]; + + const yaml1 = YAML.stringify(arr1, null, 2); + expect(yaml1).toMatchInlineSnapshot(` +"- &item0 + id: A +- *item0 +- &item1 + id: B +- &item2 + id: C +- *item1 +- *item2" +`); + + const parsed1 = YAML.parse(yaml1); + expect(parsed1[0]).toBe(parsed1[1]); + expect(parsed1[2]).toBe(parsed1[4]); + expect(parsed1[3]).toBe(parsed1[5]); + expect(parsed1[0].id).toBe("A"); + expect(parsed1[2].id).toBe("B"); + expect(parsed1[3].id).toBe("C"); + + // Test 2: Arrays in nested structures + const shared1 = [1, 2]; + const shared2 = [3, 4]; + const shared3 = [5, 6]; + + const complex = { + arrays: [ + shared1, // &item0 + shared2, // &item1 + shared1, // *item0 + ], + nested: { + moreArrays: [ + shared3, // &item2 + shared2, // *item1 + shared3, // *item2 + ], + }, + }; + + const yaml2 = YAML.stringify(complex, null, 2); + expect(yaml2).toMatchInlineSnapshot(` +"arrays: + - &item0 + - 1 + - 2 + - &item1 + - 3 + - 4 + - *item0 +nested: + moreArrays: + - &item2 + - 5 + - 6 + - *item1 + - *item2" +`); + + const parsed2 = YAML.parse(yaml2); + expect(parsed2.arrays[0]).toBe(parsed2.arrays[2]); + expect(parsed2.arrays[1]).toBe(parsed2.nested.moreArrays[1]); + expect(parsed2.nested.moreArrays[0]).toBe(parsed2.nested.moreArrays[2]); + }); + + test("handles mixed property and array anchors with name conflicts", () => { + // Test case where property name "item" conflicts with array item anchors + const objShared = { type: "object" }; + const arrShared = ["array"]; + const nestedShared = { nested: "obj" }; + + const mixed = { + item: objShared, // Gets &item (property anchor) + items: [ + arrShared, // Gets &item0 (array item anchor) + nestedShared, // Gets &item1 + arrShared, // Gets *item0 + nestedShared, // Gets *item1 + ], + refs: { + item: objShared, // Gets *item (property alias) + }, + }; + + const yaml = YAML.stringify(mixed, null, 2); + expect(yaml).toMatchInlineSnapshot(` +"item: + &item + type: object +items: + - &item0 + - array + - &item1 + nested: obj + - *item0 + - *item1 +refs: + item: + *item" +`); + + const parsed = YAML.parse(yaml); + expect(parsed.item).toBe(parsed.refs.item); + expect(parsed.items[0]).toBe(parsed.items[2]); + expect(parsed.items[1]).toBe(parsed.items[3]); + expect(parsed.item.type).toBe("object"); + expect(parsed.items[0][0]).toBe("array"); + expect(parsed.items[1].nested).toBe("obj"); + }); + + test("handles empty string property names in anchors", () => { + // Empty property names should get a counter appended + const shared = { empty: "key" }; + const more = {}; + const obj = { + "": shared, // Empty key - should get counter + nested: { + "": shared, // Same empty key - should be alias + }, + another: { + "": more, + what: more, + }, + }; + + const yaml = YAML.stringify(obj, null, 2); + expect(yaml).toMatchInlineSnapshot(` + """: + &value0 + empty: key + nested: + "": + *value0 + another: + "": + &value1 + {} + what: + *value1" + `); + // Since empty names can't be used as anchors, they get a counter + + const parsed = YAML.parse(yaml); + expect(parsed[""]).toBe(parsed.nested[""]); + expect(parsed[""].empty).toBe("key"); + }); + + test("handles complex counter scenarios with many conflicts", () => { + // Create many objects that will cause property name conflicts + const objects = Array.from({ length: 5 }, (_, i) => ({ id: i })); + + const complex = { + data: objects[0], + level1: { + data: objects[0], // alias + sub1: { + data: objects[1], // &data1 + }, + sub2: { + data: objects[1], // alias to data1 + }, + }, + level2: { + data: objects[2], // &data2 + nested: { + data: objects[3], // &data3 + deep: { + data: objects[4], // &data4 + }, + }, + }, + refs: { + data: objects[2], // alias to data2 + all: [ + { data: objects[3] }, // alias to data3 + { data: objects[4] }, // alias to data4 + ], + }, + }; + + const yaml = YAML.stringify(complex, null, 2); + expect(yaml).toMatchInlineSnapshot(` +"data: + &data + id: 0 +level1: + data: + *data + sub1: + data: + &data1 + id: 1 + sub2: + data: + *data1 +level2: + data: + &data2 + id: 2 + nested: + data: + &data3 + id: 3 + deep: + data: + &data4 + id: 4 +refs: + data: + *data2 + all: + - data: + *data3 + - data: + *data4" +`); + + const parsed = YAML.parse(yaml); + expect(parsed.data).toBe(parsed.level1.data); + expect(parsed.level1.sub1.data).toBe(parsed.level1.sub2.data); + expect(parsed.level2.data).toBe(parsed.refs.data); + expect(parsed.level2.nested.data).toBe(parsed.refs.all[0].data); + expect(parsed.level2.nested.deep.data).toBe(parsed.refs.all[1].data); + + // Verify IDs + expect(parsed.data.id).toBe(0); + expect(parsed.level1.sub1.data.id).toBe(1); + expect(parsed.level2.data.id).toBe(2); + expect(parsed.level2.nested.data.id).toBe(3); + expect(parsed.level2.nested.deep.data.id).toBe(4); + }); + + test.todo("handles root level anchors correctly", () => { + // When the root itself is referenced + const obj = { name: "root" }; + obj.self = obj; + + const yaml = YAML.stringify(obj); + expect(yaml).toContain("&root"); + expect(yaml).toContain("*root"); + + const parsed = YAML.parse(yaml); + expect(parsed.self).toBe(parsed); + expect(parsed.name).toBe("root"); + }); + + test("root collision with property name", () => { + const obj = {}; + const root = {}; + obj.cycle = obj; + obj.root = root; + obj.root2 = root; + expect(YAML.stringify(obj, null, 2)).toMatchInlineSnapshot(` + "&root + cycle: + *root + root: + &root1 + {} + root2: + *root1" + `); + }); + }); + + // JavaScript edge cases and exotic objects + describe("JavaScript edge cases", () => { + test("handles symbols", () => { + const sym = Symbol("test"); + expect(YAML.stringify(sym)).toBe(undefined); + + const obj = { + [sym]: "symbol key value", + normalKey: "normal value", + symbolValue: sym, + }; + // Symbol keys are not enumerable, symbol values are undefined + expect(YAML.stringify(obj, null, 2)).toBe("normalKey: normal value\ntest: symbol key value"); + }); + + test("handles WeakMap and WeakSet", () => { + const weakMap = new WeakMap(); + const weakSet = new WeakSet(); + const key = {}; + weakMap.set(key, "value"); + weakSet.add(key); + + expect(YAML.stringify(weakMap)).toBe("{}"); + expect(YAML.stringify(weakSet)).toBe("{}"); + }); + + test("handles ArrayBuffer and TypedArrays", () => { + const buffer = new ArrayBuffer(8); + const uint8 = new Uint8Array([1, 2, 3, 4]); + const int32 = new Int32Array([100, 200]); + const float64 = new Float64Array([3.14, 2.71]); + + expect(YAML.stringify(buffer)).toBe("{}"); + expect(YAML.stringify(uint8, null, 2)).toBe('"0": 1\n"1": 2\n"2": 3\n"3": 4'); + expect(YAML.stringify(int32, null, 2)).toBe('"0": 100\n"1": 200'); + expect(YAML.stringify(float64, null, 2)).toBe('"0": 3.14\n"1": 2.71'); + }); + + test("handles Proxy objects", () => { + const target = { a: 1, b: 2 }; + const proxy = new Proxy(target, { + get(obj, prop) { + if (prop === "c") return 3; + return obj[prop]; + }, + ownKeys(obj) { + return [...Object.keys(obj), "c"]; + }, + getOwnPropertyDescriptor(obj, prop) { + if (prop === "c") { + return { configurable: true, enumerable: true, value: 3 }; + } + return Object.getOwnPropertyDescriptor(obj, prop); + }, + }); + + const result = YAML.stringify(proxy); + expect(result).toContain("a: 1"); + expect(result).toContain("b: 2"); + expect(result).toContain("c: 3"); + }); + + test("handles Proxy that throws", () => { + const throwingProxy = new Proxy( + {}, + { + get() { + throw new Error("Proxy get trap error"); + }, + ownKeys() { + return ["key"]; + }, + getOwnPropertyDescriptor() { + return { configurable: true, enumerable: true }; + }, + }, + ); + + expect(() => YAML.stringify(throwingProxy)).toThrow("Proxy get trap error"); + }); + + test("handles getters that throw", () => { + const obj = { + normal: "value", + get throwing() { + throw new Error("Getter error"); + }, + }; + + expect(() => YAML.stringify(obj)).toThrow("Getter error"); + }); + + test("handles getters that return different values", () => { + let count = 0; + const obj = { + get counter() { + return ++count; + }, + }; + + const yaml1 = YAML.stringify(obj, null, 2); + const yaml2 = YAML.stringify(obj, null, 2); + + expect(yaml1).toBe("counter: 2"); + expect(yaml2).toBe("counter: 4"); + }); + + test.todo("handles circular getters", () => { + const obj = { + get self() { + return obj; + }, + }; + + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + // The getter returns the object itself, creating a circular reference + expect(parsed.self).toBe(parsed); + }); + + test("handles Promise objects", () => { + const promise = Promise.resolve(42); + const pendingPromise = new Promise(() => {}); + + expect(YAML.stringify(promise)).toBe("{}"); + expect(YAML.stringify(pendingPromise)).toBe("{}"); + }); + + test("handles Generator functions and iterators", () => { + function* generator() { + yield 1; + yield 2; + } + + const gen = generator(); + const genFunc = generator; + + expect(YAML.stringify(gen)).toBe("{}"); + expect(YAML.stringify(genFunc)).toBe(undefined); + }); + + test("handles AsyncFunction and async iterators", () => { + const asyncFunc = async () => 42; + async function* asyncGen() { + yield 1; + } + const asyncIterator = asyncGen(); + + expect(YAML.stringify(asyncFunc)).toBe(undefined); + expect(YAML.stringify(asyncIterator)).toBe("{}"); + }); + + test("handles objects with null prototype", () => { + const nullProto = Object.create(null); + nullProto.key = "value"; + nullProto.number = 42; + + const result = YAML.stringify(nullProto); + expect(result).toContain("key: value"); + expect(result).toContain("number: 42"); + }); + + test("handles objects with custom toJSON", () => { + const obj = { + data: "secret", + toJSON() { + return { data: "public" }; + }, + }; + + // YAML.stringify doesn't use toJSON (unlike JSON.stringify) + expect(YAML.stringify(obj, null, 2)).toContain("data: secret"); + }); + + test("handles objects with valueOf", () => { + const obj = { + value: 100, + valueOf() { + return 42; + }, + }; + + // valueOf is not called for objects + const result = YAML.stringify(obj, null, 2); + expect(result).toContain("value: 100"); + }); + + test("handles objects with toString", () => { + const obj = { + data: "test", + toString() { + return "custom string"; + }, + }; + + // toString is not called for objects + const result = YAML.stringify(obj, null, 2); + expect(result).toContain("data: test"); + }); + + test("handles frozen and sealed objects", () => { + const frozen = Object.freeze({ a: 1, b: 2 }); + const sealed = Object.seal({ x: 10, y: 20 }); + const nonExtensible = Object.preventExtensions({ foo: "bar" }); + + expect(YAML.stringify(frozen, null, 2)).toBe("a: 1\nb: 2"); + expect(YAML.stringify(sealed, null, 2)).toBe('x: 10\n"y": 20'); + expect(YAML.stringify(nonExtensible, null, 2)).toBe("foo: bar"); + }); + + test("handles objects with symbol.toPrimitive", () => { + const obj = { + normal: "value", + [Symbol.toPrimitive](hint) { + return hint === "string" ? "primitive" : 42; + }, + }; + + expect(YAML.stringify(obj, null, 2)).toBe("normal: value"); + }); + + test("handles Intl objects", () => { + const dateFormat = new Intl.DateTimeFormat("en-US"); + const numberFormat = new Intl.NumberFormat("en-US"); + const collator = new Intl.Collator("en-US"); + + expect(YAML.stringify(dateFormat)).toBe("{}"); + expect(YAML.stringify(numberFormat)).toBe("{}"); + expect(YAML.stringify(collator)).toBe("{}"); + }); + + test("handles URL and URLSearchParams", () => { + const url = new URL("https://example.com/path?query=1"); + const params = new URLSearchParams("a=1&b=2"); + + expect(YAML.stringify(url)).toBe("{}"); + expect(YAML.stringify(params)).toBe("{}"); + }); + + test("handles empty objects and arrays in various contexts", () => { + const nested = { + emptyObj: {}, + emptyArr: [], + nested: { + deepEmpty: {}, + deepArr: [], + }, + mixed: [{}, [], { inner: {} }, { inner: [] }], + }; + + const yaml = YAML.stringify(nested, null, 2); + expect(yaml).toMatchInlineSnapshot(` + "emptyObj: + {} + emptyArr: + [] + nested: + deepEmpty: + {} + deepArr: + [] + mixed: + - {} + - [] + - inner: + {} + - inner: + []" + `); + }); + + test("handles sparse arrays in objects", () => { + const obj = { + sparse: [1, , , 4], // eslint-disable-line no-sparse-arrays + normal: [1, 2, 3, 4], + }; + + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + expect(parsed.sparse).toEqual([1, 4]); + expect(parsed.normal).toEqual([1, 2, 3, 4]); + }); + + test("handles very large objects", () => { + const large = {}; + for (let i = 0; i < 10000; i++) { + large[`key${i}`] = `value${i}`; + } + + const yaml = YAML.stringify(large); + const parsed = YAML.parse(yaml); + + expect(Object.keys(parsed).length).toBe(10000); + expect(parsed.key0).toBe("value0"); + expect(parsed.key9999).toBe("value9999"); + }); + + test("handles property names that parse incorrectly", () => { + const obj = { + "key: value": "colon space key", + }; + + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + expect(parsed["key: value"]).toBe("colon space key"); + }); + + test("handles empty string keys without crashing", () => { + const obj = { "": "empty key value" }; + const yaml = YAML.stringify(obj, null, 1); + expect(yaml).toBe('"": empty key value'); + + const parsed = YAML.parse(yaml); + expect(parsed[""]).toBe("empty key value"); + }); + + test("handles arrays with sparse elements", () => { + const arr = [1, , 3, undefined, 5]; // eslint-disable-line no-sparse-arrays + const yaml = YAML.stringify(arr); + const parsed = YAML.parse(yaml); + + // Undefined and sparse elements should be filtered out + expect(parsed).toEqual([1, 3, 5]); + }); + + test("handles objects with undefined values", () => { + const obj = { + defined: "value", + undefined: undefined, + null: null, + }; + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + // Should preserve null but not undefined + expect(parsed).toEqual({ + defined: "value", + null: null, + }); + }); + + test("handles numeric object keys", () => { + const obj = { + 0: "first", + 1: "second", + 42: "answer", + }; + const yaml = YAML.stringify(obj); + const parsed = YAML.parse(yaml); + + expect(parsed).toEqual({ + "0": "first", + "1": "second", + "42": "answer", + }); + }); + }); + }); });