Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
427bb66604 Add Bun.TOON API for Token-Oriented Object Notation
Implements basic infrastructure for Bun.TOON.parse() and Bun.TOON.stringify().
TOON is a compact, human-readable format designed for passing structured data
to Large Language Models with significantly reduced token usage.

This commit adds:
- Phase 1 complete: Full API wiring for Bun.TOON object
- Bun.TOON.stringify() with support for:
  - Primitives (null, boolean, number, string)
  - Proper quote detection and escaping
  - Space parameter support (number or string)
  - Circular reference detection
- Bun.TOON.parse() stub (returns SyntaxError for now)
- Test suite with passing tests for stringify primitives
- Analytics tracking for toon_parse and toon_stringify

Files added:
- src/bun.js/api/TOONObject.zig: API wrapper calling into interchange layer
- src/interchange/toon.zig: Parser and stringifier implementation
- test/js/bun/toon/toon.test.ts: Test suite

Modified files:
- src/bun.js/bindings/BunObject.cpp: Added TOON property callback
- src/bun.js/bindings/BunObject+exports.h: Export TOON getter
- src/bun.js/api/BunObject.zig: Wire up TOON lazy property
- src/bun.js/api.zig: Export TOONObject
- src/interchange.zig: Export toon module
- src/analytics.zig: Add toon_parse and toon_stringify metrics

Phase 2 remaining: Full TOON parser and advanced stringifier features
(objects, arrays, tabular format) to be implemented in follow-up PRs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:16:53 +00:00
9 changed files with 477 additions and 0 deletions

View File

@@ -87,6 +87,8 @@ pub const Features = struct {
pub var yarn_migration: usize = 0;
pub var pnpm_migration: usize = 0;
pub var yaml_parse: usize = 0;
pub var toon_parse: usize = 0;
pub var toon_stringify: usize = 0;
comptime {
@export(&napi_module_register, .{ .name = "Bun__napi_module_register_count" });

View File

@@ -27,6 +27,7 @@ pub const Subprocess = @import("./api/bun/subprocess.zig");
pub const HashObject = @import("./api/HashObject.zig");
pub const UnsafeObject = @import("./api/UnsafeObject.zig");
pub const TOMLObject = @import("./api/TOMLObject.zig");
pub const TOONObject = @import("./api/TOONObject.zig");
pub const YAMLObject = @import("./api/YAMLObject.zig");
pub const Timer = @import("./api/Timer.zig");
pub const FFIObject = @import("./api/FFIObject.zig");

View File

@@ -62,6 +62,7 @@ pub const BunObject = struct {
pub const SHA512 = toJSLazyPropertyCallback(Crypto.SHA512.getter);
pub const SHA512_256 = toJSLazyPropertyCallback(Crypto.SHA512_256.getter);
pub const TOML = toJSLazyPropertyCallback(Bun.getTOMLObject);
pub const TOON = toJSLazyPropertyCallback(Bun.getTOONObject);
pub const YAML = toJSLazyPropertyCallback(Bun.getYAMLObject);
pub const Transpiler = toJSLazyPropertyCallback(Bun.getTranspilerConstructor);
pub const argv = toJSLazyPropertyCallback(Bun.getArgv);
@@ -127,6 +128,7 @@ pub const BunObject = struct {
@export(&BunObject.SHA512_256, .{ .name = lazyPropertyCallbackName("SHA512_256") });
@export(&BunObject.TOML, .{ .name = lazyPropertyCallbackName("TOML") });
@export(&BunObject.TOON, .{ .name = lazyPropertyCallbackName("TOON") });
@export(&BunObject.YAML, .{ .name = lazyPropertyCallbackName("YAML") });
@export(&BunObject.Glob, .{ .name = lazyPropertyCallbackName("Glob") });
@export(&BunObject.Transpiler, .{ .name = lazyPropertyCallbackName("Transpiler") });
@@ -1269,6 +1271,10 @@ pub fn getTOMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSVa
return TOMLObject.create(globalThis);
}
pub fn getTOONObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return TOONObject.create(globalThis);
}
pub fn getYAMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return YAMLObject.create(globalThis);
}
@@ -2059,6 +2065,7 @@ const api = bun.api;
const FFIObject = bun.api.FFIObject;
const HashObject = bun.api.HashObject;
const TOMLObject = bun.api.TOMLObject;
const TOONObject = bun.api.TOONObject;
const UnsafeObject = bun.api.UnsafeObject;
const YAMLObject = bun.api.YAMLObject;
const node = bun.api.node;

View File

@@ -0,0 +1,107 @@
pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue {
const object = JSValue.createEmptyObject(globalThis, 2);
object.put(
globalThis,
ZigString.static("parse"),
jsc.createCallback(
globalThis,
ZigString.static("parse"),
1,
parse,
),
);
object.put(
globalThis,
ZigString.static("stringify"),
jsc.createCallback(
globalThis,
ZigString.static("stringify"),
3,
stringify,
),
);
return object;
}
pub fn parse(
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!jsc.JSValue {
var arena = bun.ArenaAllocator.init(globalThis.allocator());
const allocator = arena.allocator();
defer arena.deinit();
var log = logger.Log.init(default_allocator);
const arguments = callframe.arguments_old(1).slice();
if (arguments.len == 0 or arguments[0].isEmptyOrUndefinedOrNull()) {
return globalThis.throwInvalidArguments("Expected a string to parse", .{});
}
var input_slice = try arguments[0].toSlice(globalThis, bun.default_allocator);
defer input_slice.deinit();
const source = &logger.Source.initPathString("input.toon", input_slice.slice());
const parse_result = TOON.parse(source, &log, allocator) catch {
return globalThis.throwValue(try log.toJS(globalThis, default_allocator, "Failed to parse toon"));
};
// Convert parsed result to JSON
const buffer_writer = js_printer.BufferWriter.init(allocator);
var writer = js_printer.BufferPrinter.init(buffer_writer);
_ = js_printer.printJSON(
*js_printer.BufferPrinter,
&writer,
parse_result,
source,
.{
.mangled_props = null,
},
) catch {
return globalThis.throwValue(try log.toJS(globalThis, default_allocator, "Failed to print toon"));
};
const slice = writer.ctx.buffer.slice();
var out = bun.String.borrowUTF8(slice);
defer out.deref();
return out.toJSByParseJSON(globalThis);
}
pub fn stringify(
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!jsc.JSValue {
const value, const replacer, const space_value = callframe.argumentsAsArray(3);
value.ensureStillAlive();
if (value.isUndefined() or value.isSymbol() or value.isFunction()) {
return .js_undefined;
}
if (!replacer.isUndefinedOrNull()) {
return globalThis.throw("TOON.stringify does not support the replacer argument", .{});
}
var scope: bun.AllocationScope = .init(bun.default_allocator);
defer scope.deinit();
var stringifier = TOON.stringify(scope.allocator(), globalThis, value, space_value) catch |err| return switch (err) {
error.OutOfMemory => error.JSError,
error.JSError, error.JSTerminated => |js_err| js_err,
error.StackOverflow => globalThis.throwStackOverflow(),
};
defer stringifier.deinit();
return stringifier.toString(globalThis);
}
const bun = @import("bun");
const default_allocator = bun.default_allocator;
const js_printer = bun.js_printer;
const logger = bun.logger;
const TOON = bun.interchange.toon.TOON;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;
const ZigString = jsc.ZigString;

View File

@@ -18,6 +18,7 @@
macro(SHA512) \
macro(SHA512_256) \
macro(TOML) \
macro(TOON) \
macro(YAML) \
macro(Transpiler) \
macro(ValkeyClient) \

View File

@@ -722,6 +722,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
SHA512 BunObject_lazyPropCb_wrap_SHA512 DontDelete|PropertyCallback
SHA512_256 BunObject_lazyPropCb_wrap_SHA512_256 DontDelete|PropertyCallback
TOML BunObject_lazyPropCb_wrap_TOML DontDelete|PropertyCallback
TOON BunObject_lazyPropCb_wrap_TOON DontDelete|PropertyCallback
YAML BunObject_lazyPropCb_wrap_YAML DontDelete|PropertyCallback
Transpiler BunObject_lazyPropCb_wrap_Transpiler DontDelete|PropertyCallback
embeddedFiles BunObject_lazyPropCb_wrap_embeddedFiles DontDelete|PropertyCallback

View File

@@ -1,3 +1,4 @@
pub const json = @import("./interchange/json.zig");
pub const toml = @import("./interchange/toml.zig");
pub const toon = @import("./interchange/toon.zig");
pub const yaml = @import("./interchange/yaml.zig");

258
src/interchange/toon.zig Normal file
View File

@@ -0,0 +1,258 @@
const std = @import("std");
const bun = @import("bun");
const logger = bun.logger;
const JSC = bun.jsc;
const ast = bun.ast;
const wtf = bun.jsc.wtf;
const Expr = ast.Expr;
const E = ast.E;
const G = ast.G;
const OOM = bun.OOM;
const JSError = bun.JSError;
/// Token-Oriented Object Notation (TOON) parser and stringifier
/// TOON is a compact, human-readable format designed for passing structured data
/// to Large Language Models with significantly reduced token usage.
pub const TOON = struct {
/// Parse TOON text into a JavaScript AST Expr
pub fn parse(source: *const logger.Source, log: *logger.Log, allocator: std.mem.Allocator) (OOM || error{SyntaxError})!Expr {
bun.analytics.Features.toon_parse += 1;
var parser = Parser.init(allocator, source.contents);
const result = parser.parseValue() catch |err| {
if (err == error.SyntaxError) {
try log.addErrorFmt(
source,
logger.Loc{ .start = @as(i32, @intCast(parser.pos)) },
allocator,
"Syntax error parsing TOON: {s}",
.{parser.error_msg orelse "unexpected input"},
);
}
return err;
};
return result;
}
/// Stringify a JavaScript value to TOON format
/// Returns a Stringifier that owns the string builder
pub fn stringify(
allocator: std.mem.Allocator,
globalThis: *JSC.JSGlobalObject,
value: JSC.JSValue,
space_value: JSC.JSValue,
) (OOM || error{ JSError, JSTerminated, StackOverflow })!Stringifier {
bun.analytics.Features.toon_stringify += 1;
var stringifier = try Stringifier.init(allocator, globalThis, space_value);
errdefer stringifier.deinit();
try stringifier.stringify(globalThis, value, 0);
return stringifier;
}
};
const Parser = struct {
allocator: std.mem.Allocator,
input: []const u8,
pos: usize = 0,
error_msg: ?[]const u8 = null,
fn init(allocator: std.mem.Allocator, input: []const u8) Parser {
return .{
.allocator = allocator,
.input = input,
};
}
fn parseValue(self: *Parser) (OOM || error{SyntaxError})!Expr {
self.skipWhitespace();
if (self.pos >= self.input.len) {
return Expr.init(E.Null, .{}, .Empty);
}
// For now, return a placeholder
// Full implementation would parse the TOON format here
self.error_msg = "TOON parsing not fully implemented yet";
return error.SyntaxError;
}
fn skipWhitespace(self: *Parser) void {
while (self.pos < self.input.len) {
switch (self.input[self.pos]) {
' ', '\t', '\r', '\n' => self.pos += 1,
else => break,
}
}
}
};
pub const Stringifier = struct {
allocator: std.mem.Allocator,
builder: wtf.StringBuilder,
indent: usize,
space: Space,
known_collections: std.AutoHashMap(JSC.JSValue, void),
const Space = union(enum) {
none,
spaces: u8,
string: []const u8,
};
pub fn toString(this: *Stringifier, global: *JSC.JSGlobalObject) JSError!JSC.JSValue {
return this.builder.toString(global);
}
fn init(
allocator: std.mem.Allocator,
globalThis: *JSC.JSGlobalObject,
space_value: JSC.JSValue,
) (OOM || error{ JSError, JSTerminated })!Stringifier {
const space = if (space_value.isNumber()) blk: {
const num = space_value.toInt32();
const clamped: u8 = @intCast(@max(0, @min(num, 10)));
if (clamped == 0) {
break :blk Space.none;
}
break :blk Space{ .spaces = clamped };
} else if (space_value.isString()) blk: {
const str = try space_value.toBunString(globalThis);
defer str.deref();
if (str.length() == 0) {
break :blk Space.none;
}
const str_utf8 = str.toUTF8(allocator);
defer str_utf8.deinit();
const str_slice = try allocator.dupe(u8, str_utf8.slice());
break :blk Space{ .string = str_slice };
} else Space.none;
return .{
.allocator = allocator,
.builder = wtf.StringBuilder.init(),
.indent = 0,
.space = space,
.known_collections = std.AutoHashMap(JSC.JSValue, void).init(allocator),
};
}
pub fn deinit(self: *Stringifier) void {
self.builder.deinit();
self.known_collections.deinit();
if (self.space == .string) {
self.allocator.free(self.space.string);
}
}
fn stringify(
self: *Stringifier,
globalThis: *JSC.JSGlobalObject,
value: JSC.JSValue,
depth: usize,
) (OOM || error{ JSError, JSTerminated, StackOverflow })!void {
_ = depth;
// Check for circular references
if (value.isObject()) {
const gop = try self.known_collections.getOrPut(value);
if (gop.found_existing) {
// Circular reference - for now just write null
self.builder.append(.latin1, "null");
return;
}
}
defer {
if (value.isObject()) {
_ = self.known_collections.remove(value);
}
}
if (value.isNull()) {
self.builder.append(.latin1, "null");
} else if (value.isUndefinedOrNull()) {
self.builder.append(.latin1, "null");
} else if (value.isBoolean()) {
if (value.asBoolean()) {
self.builder.append(.latin1, "true");
} else {
self.builder.append(.latin1, "false");
}
} else if (value.isNumber()) {
const num = value.asNumber();
if (std.math.isNan(num) or std.math.isInf(num)) {
self.builder.append(.latin1, "null");
} else {
self.builder.append(.double, num);
}
} else if (value.isString()) {
const str = try value.toBunString(globalThis);
defer str.deref();
const slice = str.toUTF8(self.allocator);
defer slice.deinit();
try self.writeString(slice.slice());
} else if (value.jsType().isArray()) {
// Placeholder for array handling
self.builder.append(.latin1, "[]");
} else if (value.isObject()) {
// Placeholder for object handling
self.builder.append(.latin1, "{}");
} else {
self.builder.append(.latin1, "null");
}
}
fn writeString(self: *Stringifier, str: []const u8) OOM!void {
// Check if quoting is needed
const needs_quotes = needsQuotes(str);
if (needs_quotes) {
self.builder.append(.lchar, '"');
for (str) |c| {
switch (c) {
'"' => self.builder.append(.latin1, "\\\""),
'\\' => self.builder.append(.latin1, "\\\\"),
'\n' => self.builder.append(.latin1, "\\n"),
'\r' => self.builder.append(.latin1, "\\r"),
'\t' => self.builder.append(.latin1, "\\t"),
else => self.builder.append(.lchar, c),
}
}
self.builder.append(.lchar, '"');
} else {
self.builder.append(.latin1, str);
}
}
fn needsQuotes(str: []const u8) bool {
if (str.len == 0) return true;
// Check for leading/trailing spaces
if (str[0] == ' ' or str[str.len - 1] == ' ') return true;
// Check for special characters or keywords
if (std.mem.eql(u8, str, "true") or
std.mem.eql(u8, str, "false") or
std.mem.eql(u8, str, "null")) return true;
// Check if it looks like a number
if (str[0] >= '0' and str[0] <= '9') return true;
if (str[0] == '-' and str.len > 1 and str[1] >= '0' and str[1] <= '9') return true;
// Check for characters that need quoting
for (str) |c| {
switch (c) {
':', ',', '"', '\\', '\n', '\r', '\t', '[', ']', '{', '}' => return true,
else => {},
}
}
return false;
}
};

View File

@@ -0,0 +1,99 @@
import { TOON } from "bun";
import { describe, expect, test } from "bun:test";
describe("Bun.TOON", () => {
test("TOON object exists", () => {
expect(TOON).toBeDefined();
expect(TOON.parse).toBeDefined();
expect(TOON.stringify).toBeDefined();
});
describe("TOON.stringify", () => {
test("stringify null", () => {
expect(TOON.stringify(null)).toBe("null");
});
test("stringify boolean", () => {
expect(TOON.stringify(true)).toBe("true");
expect(TOON.stringify(false)).toBe("false");
});
test("stringify number", () => {
expect(TOON.stringify(42)).toBe("42");
expect(TOON.stringify(3.14)).toBe("3.14");
expect(TOON.stringify(0)).toBe("0");
});
test("stringify string", () => {
expect(TOON.stringify("hello")).toBe("hello");
expect(TOON.stringify("hello world")).toBe("hello world");
});
test("stringify string with special characters", () => {
expect(TOON.stringify("hello, world")).toBe('"hello, world"');
expect(TOON.stringify("true")).toBe('"true"');
expect(TOON.stringify("false")).toBe('"false"');
expect(TOON.stringify("123")).toBe('"123"');
});
test("stringify empty string", () => {
expect(TOON.stringify("")).toBe('""');
});
test("stringify simple object", () => {
const result = TOON.stringify({ name: "Alice", age: 30 });
// For now, just check it doesn't crash
expect(result).toBeDefined();
});
test("stringify array", () => {
const result = TOON.stringify(["a", "b", "c"]);
// For now, just check it doesn't crash
expect(result).toBeDefined();
});
});
describe("TOON.parse", () => {
test.todo("parse null", () => {
expect(TOON.parse("null")).toBe(null);
});
test.todo("parse boolean", () => {
expect(TOON.parse("true")).toBe(true);
expect(TOON.parse("false")).toBe(false);
});
test.todo("parse number", () => {
expect(TOON.parse("42")).toBe(42);
expect(TOON.parse("3.14")).toBe(3.14);
});
test.todo("parse string", () => {
expect(TOON.parse("hello")).toBe("hello");
expect(TOON.parse('"hello, world"')).toBe("hello, world");
});
test.todo("parse simple object", () => {
const result = TOON.parse("name: Alice\nage: 30");
expect(result).toEqual({ name: "Alice", age: 30 });
});
test.todo("parse array", () => {
const result = TOON.parse("items[3]: a,b,c");
expect(result).toEqual({ items: ["a", "b", "c"] });
});
});
describe("round-trip", () => {
test.todo("null round-trip", () => {
const value = null;
expect(TOON.parse(TOON.stringify(value))).toEqual(value);
});
test.todo("simple values round-trip", () => {
expect(TOON.parse(TOON.stringify(true))).toBe(true);
expect(TOON.parse(TOON.stringify(42))).toBe(42);
expect(TOON.parse(TOON.stringify("hello"))).toBe("hello");
});
});
});