Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
d24858da6d Improve Bun.YAML.stringify implementation
- Fix circular reference detection to only add anchors for truly circular objects
- Improve array formatting, especially nested arrays (- - element format)
- Fix object indentation in arrays and nested structures
- Add comprehensive test coverage for circular references
- Better string handling and YAML spec compliance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 20:31:39 +00:00
Claude Bot
18896d297a wip 2025-07-24 19:19:51 +00:00
6 changed files with 700 additions and 0 deletions

View File

@@ -92,6 +92,7 @@ src/bun.js/bindings/JSWrappingFunction.cpp
src/bun.js/bindings/JSX509Certificate.cpp
src/bun.js/bindings/JSX509CertificateConstructor.cpp
src/bun.js/bindings/JSX509CertificatePrototype.cpp
src/bun.js/bindings/JSYAML.cpp
src/bun.js/bindings/linux_perf_tracing.cpp
src/bun.js/bindings/MarkingConstraint.cpp
src/bun.js/bindings/ModuleLoader.cpp

View File

@@ -86,6 +86,8 @@ static JSValue BunObject_getter_wrap_ArrayBufferSink(VM& vm, JSObject* bunObject
static JSValue constructCookieObject(VM& vm, JSObject* bunObject);
static JSValue constructCookieMapObject(VM& vm, JSObject* bunObject);
extern JSValue constructYAMLObject(VM& vm, JSObject* bunObject);
static JSValue constructYAMLObjectWrapper(VM& vm, JSObject* bunObject);
static JSValue constructEnvObject(VM& vm, JSObject* object)
{
@@ -710,6 +712,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
SHA512 BunObject_getter_wrap_SHA512 DontDelete|PropertyCallback
SHA512_256 BunObject_getter_wrap_SHA512_256 DontDelete|PropertyCallback
TOML BunObject_getter_wrap_TOML DontDelete|PropertyCallback
YAML constructYAMLObjectWrapper DontDelete|PropertyCallback
Transpiler BunObject_getter_wrap_Transpiler DontDelete|PropertyCallback
embeddedFiles BunObject_getter_wrap_embeddedFiles DontDelete|PropertyCallback
S3Client BunObject_getter_wrap_S3Client DontDelete|PropertyCallback
@@ -867,6 +870,13 @@ static JSValue constructCookieMapObject(VM& vm, JSObject* bunObject)
return WebCore::JSCookieMap::getConstructor(vm, zigGlobalObject);
}
extern JSValue constructYAMLObject(VM& vm, JSObject* bunObject);
static JSValue constructYAMLObjectWrapper(VM& vm, JSObject* bunObject)
{
return constructYAMLObject(vm, bunObject);
}
JSC::JSObject* createBunObject(VM& vm, JSObject* globalObject)
{
return JSBunObject::create(vm, jsCast<Zig::GlobalObject*>(globalObject));

View File

@@ -0,0 +1,416 @@
#include "root.h"
#include <JavaScriptCore/ObjectConstructor.h>
#include <JavaScriptCore/JSArray.h>
#include <JavaScriptCore/DateInstance.h>
#include <JavaScriptCore/PropertyNameArray.h>
#include <wtf/text/StringBuilder.h>
#include <wtf/HashMap.h>
#include <wtf/HashSet.h>
#include "ZigGlobalObject.h"
#include "helpers.h"
#include "wtf-bindings.h"
using namespace JSC;
namespace Bun {
static String escapeYAMLString(const String& str)
{
// Check if string needs quoting
bool needsQuotes = false;
// YAML reserved words and numeric strings
if (str == "true"_s || str == "false"_s || str == "null"_s || str == "~"_s ||
str == "yes"_s || str == "no"_s || str == "on"_s || str == "off"_s) {
needsQuotes = true;
}
// Check if string looks like a number
if (!needsQuotes && !str.isEmpty()) {
bool isNumeric = true;
bool hasDot = false;
for (unsigned i = 0; i < str.length(); i++) {
auto ch = str[i];
if (i == 0 && (ch == '-' || ch == '+')) {
continue; // Allow leading sign
}
if (ch == '.' && !hasDot) {
hasDot = true;
continue;
}
if (ch < '0' || ch > '9') {
isNumeric = false;
break;
}
}
if (isNumeric) {
needsQuotes = true;
}
}
// Check for leading/trailing whitespace or internal spaces
if (!str.isEmpty()) {
if (str[0] == ' ' || str[str.length()-1] == ' ') {
needsQuotes = true;
}
// Check for internal spaces when used as keys (this is a simplified check)
for (unsigned i = 0; i < str.length(); i++) {
if (str[i] == ' ') {
needsQuotes = true;
break;
}
}
}
// Check for special characters
for (unsigned i = 0; i < str.length(); i++) {
auto ch = str[i];
if (ch == '"' || ch == '\n' || ch == '\r' || ch == '\t' || ch == '\\' ||
ch == ':' || ch == '[' || ch == ']' || ch == '{' || ch == '}' ||
ch == '#' || ch == '&' || ch == '*' || ch == '!' || ch == '|' ||
ch == '>' || ch == '\'' || ch == '%' || ch == '@' || ch == '`') {
needsQuotes = true;
break;
}
}
if (!needsQuotes) {
return str;
}
// Escape and quote the string
StringBuilder result;
result.append('"');
for (unsigned i = 0; i < str.length(); i++) {
auto ch = str[i];
switch (ch) {
case '"':
result.append("\\\""_s);
break;
case '\\':
result.append("\\\\"_s);
break;
case '\n':
result.append("\\n"_s);
break;
case '\r':
result.append("\\r"_s);
break;
case '\t':
result.append("\\t"_s);
break;
default:
result.append(ch);
}
}
result.append('"');
return result.toString();
}
// Forward declarations
static String serializeYAMLValue(JSGlobalObject* globalObject, JSValue value, unsigned indent, HashMap<JSObject*, unsigned>& objectMap, unsigned& anchorCounter, HashSet<JSObject*>& visitedForCircular);
// Pre-pass to detect circular references
static void detectCircularReferences(JSGlobalObject* globalObject, JSValue value, HashSet<JSObject*>& visiting, HashSet<JSObject*>& circular)
{
if (!value.isObject()) {
return;
}
JSObject* object = value.getObject();
if (visiting.contains(object)) {
circular.add(object);
return;
}
if (circular.contains(object)) {
return;
}
visiting.add(object);
if (value.inherits<JSArray>()) {
JSArray* array = jsCast<JSArray*>(object);
auto length = array->length();
for (unsigned i = 0; i < length; i++) {
JSValue element = array->getIndex(globalObject, i);
detectCircularReferences(globalObject, element, visiting, circular);
}
} else {
auto& vm = globalObject->vm();
PropertyNameArray propertyNames(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude);
object->getOwnNonIndexPropertyNames(globalObject, propertyNames, DontEnumPropertiesMode::Exclude);
for (auto& propertyName : propertyNames) {
JSValue propValue = object->get(globalObject, propertyName);
detectCircularReferences(globalObject, propValue, visiting, circular);
}
}
visiting.remove(object);
}
static String serializeYAMLArray(JSGlobalObject* globalObject, JSArray* array, unsigned indent, HashMap<JSObject*, unsigned>& objectMap, unsigned& anchorCounter, HashSet<JSObject*>& visitedForCircular)
{
auto length = array->length();
if (length == 0) {
return String("[]"_s);
}
StringBuilder result;
StringBuilder indentBuilder;
for (unsigned i = 0; i < indent; i++) {
indentBuilder.append(' ');
}
String indentStr = indentBuilder.toString();
for (unsigned i = 0; i < length; i++) {
if (i > 0) {
result.append('\n');
}
result.append(indentStr);
result.append("- "_s);
JSValue element = array->getIndex(globalObject, i);
String serializedElement = serializeYAMLValue(globalObject, element, indent + 2, objectMap, anchorCounter, visitedForCircular);
if (element.inherits<JSArray>() && !serializedElement.startsWith("*"_s)) {
// For nested arrays, we want: "- - first_element\n - second_element\n ..."
// The serializedElement comes with indentation, we need to restructure it
auto lines = serializedElement.split('\n');
if (lines.size() > 0) {
// First line should become "- first_element" (removing leading spaces and first dash)
String firstLine = lines[0];
unsigned trimStart = 0;
while (trimStart < firstLine.length() && firstLine[trimStart] == ' ') {
trimStart++;
}
// Should now be at "- element", we want just "- element"
result.append(firstLine.substring(trimStart));
// Subsequent lines should be indented to align under the first element
for (size_t lineIdx = 1; lineIdx < lines.size(); lineIdx++) {
result.append('\n');
result.append(indentStr);
result.append(" "_s); // Align with the content after "- "
String line = lines[lineIdx];
// Remove the original indentation
unsigned lineTrimStart = 0;
while (lineTrimStart < line.length() && line[lineTrimStart] == ' ') {
lineTrimStart++;
}
result.append(line.substring(lineTrimStart));
}
}
} else if (element.isObject() && !serializedElement.startsWith("*"_s)) {
// Objects should have their first property on the same line as "- "
// and subsequent properties indented to align with the first
auto lines = serializedElement.split('\n');
if (lines.size() > 0) {
// First line should be after "- " with no extra indentation
String firstLine = lines[0];
// Remove leading whitespace since we already have "- "
unsigned trimStart = 0;
while (trimStart < firstLine.length() && firstLine[trimStart] == ' ') {
trimStart++;
}
result.append(firstLine.substring(trimStart));
// Subsequent lines should be indented to align with the start of the first property
for (size_t lineIdx = 1; lineIdx < lines.size(); lineIdx++) {
result.append('\n');
result.append(indentStr);
result.append(" "_s); // Align with the content after "- "
String line = lines[lineIdx];
// Remove the original indentation since we're adding our own
unsigned lineTrimStart = 0;
while (lineTrimStart < line.length() && line[lineTrimStart] == ' ') {
lineTrimStart++;
}
result.append(line.substring(lineTrimStart));
}
}
} else {
result.append(serializedElement);
}
}
return result.toString();
}
static String serializeYAMLObject(JSGlobalObject* globalObject, JSObject* object, unsigned indent, HashMap<JSObject*, unsigned>& objectMap, unsigned& anchorCounter, HashSet<JSObject*>& visitedForCircular)
{
auto& vm = globalObject->vm();
PropertyNameArray propertyNames(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude);
object->getOwnNonIndexPropertyNames(globalObject, propertyNames, DontEnumPropertiesMode::Exclude);
if (propertyNames.size() == 0) {
return String("{}"_s);
}
StringBuilder result;
StringBuilder indentBuilder;
for (unsigned i = 0; i < indent; i++) {
indentBuilder.append(' ');
}
String indentStr = indentBuilder.toString();
bool first = true;
for (auto& propertyName : propertyNames) {
if (!first) {
result.append('\n');
}
first = false;
result.append(indentStr);
String keyStr = propertyName.string();
result.append(escapeYAMLString(keyStr));
result.append(": "_s);
JSValue value = object->get(globalObject, propertyName);
String serializedValue = serializeYAMLValue(globalObject, value, indent + 2, objectMap, anchorCounter, visitedForCircular);
if (value.isObject() && (value.inherits<JSArray>() || value.inherits<JSObject>())) {
if (serializedValue.startsWith("*"_s)) {
// For aliases, keep them on the same line
result.append(serializedValue);
} else {
result.append('\n');
result.append(serializedValue);
}
} else {
result.append(serializedValue);
}
}
return result.toString();
}
static String serializeYAMLValue(JSGlobalObject* globalObject, JSValue value, unsigned indent, HashMap<JSObject*, unsigned>& objectMap, unsigned& anchorCounter, HashSet<JSObject*>& visitedForCircular)
{
auto& vm = globalObject->vm();
if (value.isNull()) {
return String("null"_s);
}
if (value.isUndefined()) {
return String("null"_s); // YAML doesn't have undefined, use null
}
if (value.isBoolean()) {
return String(value.asBoolean() ? "true"_s : "false"_s);
}
if (value.isNumber()) {
double num = value.asNumber();
if (std::isnan(num)) {
return String(".nan"_s);
}
if (std::isinf(num)) {
return num > 0 ? String(".inf"_s) : String("-.inf"_s);
}
return String::number(num);
}
if (value.isString()) {
return escapeYAMLString(value.toWTFString(globalObject));
}
if (value.inherits<DateInstance>()) {
auto* dateInstance = jsCast<DateInstance*>(value);
double timeValue = dateInstance->internalNumber();
if (std::isnan(timeValue)) {
return String("null"_s);
}
char buffer[64];
size_t length = toISOString(vm, timeValue, buffer);
return String::fromUTF8(std::span<const char>(buffer, length));
}
if (value.isObject()) {
JSObject* object = value.getObject();
// Check for circular references
auto it = objectMap.find(object);
if (it != objectMap.end()) {
// Already seen this object, use alias
StringBuilder alias;
alias.append("*anchor"_s);
alias.append(String::number(it->value));
return alias.toString();
}
// Only add anchors for objects that are actually circular
if (visitedForCircular.contains(object)) {
objectMap.set(object, ++anchorCounter);
StringBuilder anchor;
anchor.append("&anchor"_s);
anchor.append(String::number(anchorCounter));
anchor.append(' ');
if (value.inherits<JSArray>()) {
anchor.append(serializeYAMLArray(globalObject, jsCast<JSArray*>(object), indent, objectMap, anchorCounter, visitedForCircular));
} else {
anchor.append(serializeYAMLObject(globalObject, object, indent, objectMap, anchorCounter, visitedForCircular));
}
return anchor.toString();
} else {
if (value.inherits<JSArray>()) {
return serializeYAMLArray(globalObject, jsCast<JSArray*>(object), indent, objectMap, anchorCounter, visitedForCircular);
} else {
return serializeYAMLObject(globalObject, object, indent, objectMap, anchorCounter, visitedForCircular);
}
}
}
return String("null"_s);
}
JSC_DEFINE_HOST_FUNCTION(yamlStringify, (JSGlobalObject* globalObject, CallFrame* callFrame))
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
if (callFrame->argumentCount() < 1) {
throwTypeError(globalObject, scope, "YAML.stringify requires at least 1 argument"_s);
return JSValue::encode(jsUndefined());
}
JSValue value = callFrame->uncheckedArgument(0);
// First pass: detect circular references
HashSet<JSObject*> visiting;
HashSet<JSObject*> circular;
detectCircularReferences(globalObject, value, visiting, circular);
// Second pass: serialize with circular reference handling
HashMap<JSObject*, unsigned> objectMap;
unsigned anchorCounter = 0;
String result = serializeYAMLValue(globalObject, value, 0, objectMap, anchorCounter, circular);
RETURN_IF_EXCEPTION(scope, encodedJSValue());
return JSValue::encode(jsString(vm, result));
}
JSValue constructYAMLObject(VM& vm, JSObject* bunObject)
{
JSGlobalObject* globalObject = bunObject->globalObject();
JSObject* yamlObject = constructEmptyObject(globalObject);
yamlObject->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "stringify"_s), 1, yamlStringify, ImplementationVisibility::Public, NoIntrinsic, PropertyAttribute::DontDelete | 0);
return yamlObject;
}
} // namespace Bun

View File

@@ -0,0 +1,56 @@
import { test, expect } from "bun:test";
test("YAML.stringify does not add anchors for non-circular objects", () => {
const obj = { name: "test", value: 42 };
const arr = [obj, obj]; // Same object referenced twice, but not circular
const result = Bun.YAML.stringify(arr);
// Should not contain anchor/alias syntax since it's not truly circular
expect(result).not.toContain("&anchor");
expect(result).not.toContain("*anchor");
// Should just serialize normally (may duplicate the object)
expect(result).toContain("name: test");
expect(result).toContain("value: 42");
});
test("YAML.stringify handles true circular references", () => {
const obj: any = { name: "test" };
obj.self = obj; // True circular reference
const result = Bun.YAML.stringify(obj);
// Should contain anchor/alias syntax for circular reference
expect(result).toContain("&anchor");
expect(result).toContain("*anchor");
expect(result).toContain("name: test");
});
test("YAML.stringify handles circular array references", () => {
const arr: any = [1, 2];
arr.push(arr); // Circular array reference
const result = Bun.YAML.stringify(arr);
// Should contain anchor/alias syntax for circular reference
expect(result).toContain("&anchor");
expect(result).toContain("*anchor");
expect(result).toContain("- 1");
expect(result).toContain("- 2");
});
test("YAML.stringify handles complex circular structures", () => {
const a: any = { name: "a" };
const b: any = { name: "b" };
a.ref = b;
b.ref = a; // Circular reference between two objects
const result = Bun.YAML.stringify({ root: a });
// Should handle the circular reference properly
expect(result).toContain("&anchor");
expect(result).toContain("*anchor");
expect(result).toContain("name: a");
expect(result).toContain("name: b");
});

View File

@@ -0,0 +1,10 @@
import { test, expect } from "bun:test";
test("YAML object exists", () => {
expect(Bun.YAML).toBeDefined();
});
test("YAML.stringify exists", () => {
expect(Bun.YAML.stringify).toBeDefined();
expect(typeof Bun.YAML.stringify).toBe("function");
});

View File

@@ -0,0 +1,207 @@
import { test, expect } from "bun:test";
test("Bun.YAML exists", () => {
expect(Bun.YAML).toBeDefined();
expect(typeof Bun.YAML).toBe("object");
});
test("Bun.YAML.stringify exists", () => {
expect(Bun.YAML.stringify).toBeDefined();
expect(typeof Bun.YAML.stringify).toBe("function");
});
test("YAML.stringify basic values", () => {
expect(Bun.YAML.stringify(null)).toBe("null");
expect(Bun.YAML.stringify(undefined)).toBe("null");
expect(Bun.YAML.stringify(true)).toBe("true");
expect(Bun.YAML.stringify(false)).toBe("false");
expect(Bun.YAML.stringify(42)).toBe("42");
expect(Bun.YAML.stringify(3.14)).toBe("3.14");
expect(Bun.YAML.stringify("hello")).toBe("hello");
});
test("YAML.stringify strings requiring quotes", () => {
expect(Bun.YAML.stringify("true")).toBe('"true"');
expect(Bun.YAML.stringify("false")).toBe('"false"');
expect(Bun.YAML.stringify("null")).toBe('"null"');
expect(Bun.YAML.stringify("123")).toBe('"123"');
expect(Bun.YAML.stringify("hello: world")).toBe('"hello: world"');
expect(Bun.YAML.stringify("- item")).toBe('"- item"');
expect(Bun.YAML.stringify(" leading space")).toBe('" leading space"');
expect(Bun.YAML.stringify("trailing space ")).toBe('"trailing space "');
});
test("YAML.stringify string escaping", () => {
expect(Bun.YAML.stringify('hello "world"')).toBe('"hello \\"world\\""');
expect(Bun.YAML.stringify("hello\\world")).toBe('"hello\\\\world"');
expect(Bun.YAML.stringify("hello\\nworld")).toBe('"hello\\\\nworld"');
expect(Bun.YAML.stringify("hello\nworld")).toBe('"hello\\nworld"');
expect(Bun.YAML.stringify("hello\tworld")).toBe('"hello\\tworld"');
expect(Bun.YAML.stringify("hello\rworld")).toBe('"hello\\rworld"');
});
test("YAML.stringify special numbers", () => {
expect(Bun.YAML.stringify(NaN)).toBe(".nan");
expect(Bun.YAML.stringify(Infinity)).toBe(".inf");
expect(Bun.YAML.stringify(-Infinity)).toBe("-.inf");
});
test("YAML.stringify empty array", () => {
expect(Bun.YAML.stringify([])).toBe("[]");
});
test("YAML.stringify simple array", () => {
const result = Bun.YAML.stringify([1, 2, 3]);
const expected = "- 1\n- 2\n- 3";
expect(result).toBe(expected);
});
test("YAML.stringify nested array", () => {
const result = Bun.YAML.stringify([1, [2, 3], 4]);
const expected = "- 1\n- - 2\n - 3\n- 4";
expect(result).toBe(expected);
});
test("YAML.stringify empty object", () => {
expect(Bun.YAML.stringify({})).toBe("{}");
});
test("YAML.stringify simple object", () => {
const result = Bun.YAML.stringify({ a: 1, b: 2 });
// Objects may have different property order, so check both possibilities
expect(result === "a: 1\nb: 2" || result === "b: 2\na: 1").toBe(true);
});
test("YAML.stringify nested object", () => {
const obj = {
name: "test",
nested: {
value: 42
}
};
const result = Bun.YAML.stringify(obj);
// Check that it contains the expected structure
expect(result).toContain("name: test");
expect(result).toContain("nested:");
expect(result).toContain(" value: 42");
});
test("YAML.stringify array with objects", () => {
const arr = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 }
];
const result = Bun.YAML.stringify(arr);
expect(result).toContain("- name: Alice");
expect(result).toContain(" age: 30");
expect(result).toContain("- name: Bob");
expect(result).toContain(" age: 25");
});
test("YAML.stringify Date objects", () => {
const date = new Date("2023-01-01T00:00:00.000Z");
const result = Bun.YAML.stringify(date);
expect(result).toBe("2023-01-01T00:00:00.000Z");
});
test("YAML.stringify invalid Date", () => {
const invalidDate = new Date("invalid");
const result = Bun.YAML.stringify(invalidDate);
expect(result).toBe("null");
});
test("YAML.stringify circular reference", () => {
const obj: any = { name: "test" };
obj.self = obj;
const result = Bun.YAML.stringify(obj);
// Should contain anchor/alias syntax for circular reference
expect(result).toContain("*anchor");
});
test("YAML.stringify circular reference in array", () => {
const arr: any = [1, 2];
arr.push(arr);
const result = Bun.YAML.stringify(arr);
// Should contain anchor/alias syntax for circular reference
expect(result).toContain("*anchor");
});
test("YAML.stringify complex nested structure", () => {
const complex = {
users: [
{ name: "Alice", active: true, scores: [85, 92, 78] },
{ name: "Bob", active: false, scores: [90, 88, 95] }
],
config: {
version: "1.0",
settings: {
debug: true,
timeout: 5000
}
}
};
const result = Bun.YAML.stringify(complex);
// Verify basic structure is present
expect(result).toContain("users:");
expect(result).toContain("- name: Alice");
expect(result).toContain("active: true");
expect(result).toContain("scores:");
expect(result).toContain("- 85");
expect(result).toContain("config:");
expect(result).toContain("version: \"1.0\"");
expect(result).toContain("settings:");
expect(result).toContain("debug: true");
expect(result).toContain("timeout: 5000");
});
test("YAML.stringify with null and undefined values", () => {
const obj = {
nullValue: null,
undefinedValue: undefined,
normalValue: "test"
};
const result = Bun.YAML.stringify(obj);
expect(result).toContain("nullValue: null");
expect(result).toContain("undefinedValue: null");
expect(result).toContain("normalValue: test");
});
test("YAML.stringify preserves array order", () => {
const arr = ["first", "second", "third"];
const result = Bun.YAML.stringify(arr);
const lines = result.split('\n');
expect(lines[0]).toBe("- first");
expect(lines[1]).toBe("- second");
expect(lines[2]).toBe("- third");
});
test("YAML.stringify handles special YAML characters in keys", () => {
const obj = {
"key:with:colons": "value1",
"key-with-dashes": "value2",
"key with spaces": "value3",
"key[with]brackets": "value4"
};
const result = Bun.YAML.stringify(obj);
expect(result).toContain('"key:with:colons": value1');
expect(result).toContain('"key with spaces": value3');
expect(result).toContain('"key[with]brackets": value4');
});
test("YAML.stringify error handling", () => {
// Test with no arguments
expect(() => Bun.YAML.stringify()).toThrow();
});