diff --git a/Cargo.lock b/Cargo.lock index df85e88428..e0e0a153e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -220,7 +220,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1269,7 +1269,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1281,7 +1281,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -1931,7 +1931,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2236,7 +2236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2676,7 +2676,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -2797,7 +2797,7 @@ dependencies = [ "gobject-sys 0.22.6", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3940,7 +3940,7 @@ checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console 0.16.4", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -4062,7 +4062,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4691,6 +4691,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mimalloc" version = "0.1.52" @@ -4939,6 +4961,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "node-semver" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1a233ea5dc37d2cfba31cfc87a5a56cc2a9c04e3672c15d179ca118dae40a7" +dependencies = [ + "bytecount", + "miette", + "nom 7.1.3", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "nom" version = "7.1.3" @@ -5006,7 +5041,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6345,6 +6380,7 @@ dependencies = [ "libmimalloc-sys", "mach2", "mimalloc", + "node-semver", "perry-diagnostics", "perry-dispatch", "perry-parser", @@ -6362,12 +6398,16 @@ dependencies = [ "temporal_rs", "thiserror 1.0.69", "timezone_provider", + "toml", "unicode-normalization", "unicode-segmentation", + "unsafe-libyaml", "url", "windows-sys 0.61.2", "writeable", "x509-cert", + "xxhash-rust", + "zstd", ] [[package]] @@ -7835,7 +7875,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8476,7 +8516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8753,7 +8793,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8885,7 +8925,7 @@ dependencies = [ "swc_eq_ignore_macros", "swc_visit", "tracing", - "unicode-width", + "unicode-width 0.2.2", "url", ] @@ -9303,7 +9343,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9900,7 +9940,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9972,6 +10012,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -10512,7 +10558,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 0daeb23e82..edb9db9a56 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1102,6 +1102,14 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // Implementation: perry-runtime `bun_compat`. method("bun", "stringWidth", false, None), method("bun", "hash", false, None), + method("bun", "deepEquals", false, None), + method("bun", "stripANSI", false, None), + method("bun", "wrapAnsi", false, None), + method("bun", "which", false, None), + method("bun", "zstdDecompress", true, None), + method("bun", "zstdDecompressSync", false, None), + method("bun", "gc", false, None), + method("bun", "generateHeapSnapshot", false, None), method("bun", "file", false, None), method("bun", "write", false, None), method("bun", "pathToFileURL", false, None), @@ -1116,6 +1124,10 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ property("bun", "stderr"), property("bun", "version"), property("bun", "isStandaloneExecutable"), + property("bun", "YAML"), + property("bun", "TOML"), + property("bun", "semver"), + property("bun", "JSONL"), // --- qs (issue #8751) --- // Native nested query-string codec. This keeps Stripe's request encoder // off qs' legacy get-intrinsic/ES-shims dependency chain. diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index dd183e07ab..e3a0256ffe 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -28,6 +28,78 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ args: &[NA_F64, NA_F64], ret: NR_F64, }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "deepEquals", + class_filter: None, + runtime: "js_bun_deep_equals", + args: &[NA_F64, NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "stripANSI", + class_filter: None, + runtime: "js_bun_strip_ansi", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "wrapAnsi", + class_filter: None, + runtime: "js_bun_wrap_ansi", + args: &[NA_F64, NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "which", + class_filter: None, + runtime: "js_bun_which", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "zstdDecompress", + class_filter: None, + runtime: "js_bun_zstd_decompress", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "zstdDecompressSync", + class_filter: None, + runtime: "js_bun_zstd_decompress_sync", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "gc", + class_filter: None, + runtime: "js_bun_gc", + args: &[NA_F64], + ret: NR_F64, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "generateHeapSnapshot", + class_filter: None, + runtime: "js_bun_generate_heap_snapshot", + args: &[NA_F64, NA_F64], + ret: NR_F64, + }, NativeModSig { module: "bun", has_receiver: false, diff --git a/crates/perry-hir/src/lower/expr_call/module_class_static.rs b/crates/perry-hir/src/lower/expr_call/module_class_static.rs index ed8f4652d0..e9ac036358 100644 --- a/crates/perry-hir/src/lower/expr_call/module_class_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_class_static.rs @@ -75,6 +75,14 @@ pub(super) fn try_module_class_static( // is a String method on the property value. | ("os", "EOL") | ("os", "devNull") + // Bun's utility namespaces are object-valued + // exports. Their methods are native closures, + // not class statics on the `bun` dispatcher. + | ("bun", "YAML") + | ("bun", "TOML") + | ("bun", "semver") + | ("bun", "JSONL") + | ("bun", "hash") ); // Unimplemented-API gate (#463) for the chained // `mod.X.Y()` case. The lower_member gate fires diff --git a/crates/perry-hir/src/lower/expr_call/module_static.rs b/crates/perry-hir/src/lower/expr_call/module_static.rs index 0606625b65..822d95bd50 100644 --- a/crates/perry-hir/src/lower/expr_call/module_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_static.rs @@ -77,6 +77,14 @@ pub(super) fn try_module_static_methods( method_ident.sym.as_ref(), "stringWidth" | "hash" + | "deepEquals" + | "stripANSI" + | "wrapAnsi" + | "which" + | "zstdDecompress" + | "zstdDecompressSync" + | "gc" + | "generateHeapSnapshot" | "file" | "write" | "pathToFileURL" diff --git a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs index 1b3f7f9fe9..64ee68890a 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module/imported_module_dispatch.rs @@ -112,6 +112,18 @@ pub(super) fn try_imported_module_dispatch( if module_name == "bun:ffi" && imported_method == Some("read") { return Ok(Err(args)); } + // `import { YAML } from "bun"; YAML.parse(...)` and the other + // #9600 value namespaces call closures stored on that value. + // Do not reinterpret `.parse`/`.order`/`.xxHash64` as a + // top-level `bun` native method. + if module_name == "bun" + && matches!( + imported_method, + Some("YAML" | "TOML" | "semver" | "JSONL" | "hash") + ) + { + return Ok(Err(args)); + } if module_name.strip_prefix("node:").unwrap_or(module_name) == "vm" && imported_method.is_none() && method_name == "Module" diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 5d2e67b391..23dd10c385 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -326,6 +326,18 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re | "stderr" | "stringWidth" | "hash" + | "YAML" + | "TOML" + | "semver" + | "JSONL" + | "deepEquals" + | "stripANSI" + | "wrapAnsi" + | "which" + | "zstdDecompress" + | "zstdDecompressSync" + | "gc" + | "generateHeapSnapshot" | "file" | "write" | "Glob" diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index d7822cf070..fee07d56c7 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -22,7 +22,7 @@ crate-type = ["rlib"] # actually needs (see optimized_libs.rs), so the heavy subsystems below # (regex engine, Temporal, URL/IDNA, normalize, segmenter) are *opt-in per # app* and absent from binaries that never use them. -default = ["full", "regex-engine", "temporal", "url-engine", "string-normalize", "intl-segmenter", "intl-namespace", "global-math", "global-json", "global-reflect", "global-atomics", "global-url", "global-text", "global-websocket", "global-webcrypto", "global-webfetch", "proc-ipc", "intl-locale", "intl-datetime", "diagnostics", "mod-dgram", "mod-http2-constants", "mod-node-test", "dyn-eval", "keepalive-anchors", "alloc-mimalloc"] +default = ["full", "regex-engine", "temporal", "url-engine", "string-normalize", "intl-segmenter", "intl-namespace", "global-math", "global-json", "global-reflect", "global-atomics", "global-url", "global-text", "global-websocket", "global-webcrypto", "global-webfetch", "proc-ipc", "intl-locale", "intl-datetime", "diagnostics", "mod-dgram", "mod-http2-constants", "mod-node-test", "bun-cli-utils", "dyn-eval", "keepalive-anchors", "alloc-mimalloc"] # Compile the ~490 `#[used]` keep-alive anchor statics (`KEEP_*`, `keep!`, # `K*`/`G*` fn-pointer statics) that pin codegen-facing `#[no_mangle]` entry # points so they survive into the staticlib archive. Without these anchors, @@ -105,6 +105,17 @@ mod-http2-constants = [] # `process.getBuiltinModule` (whose target is only known at runtime), the same # way `mod-http2-constants` does. Pure cfg gate, no extra deps. mod-node-test = [] +# Bun's small data/CLI utility pack (#9600): YAML/TOML parsing, node-semver +# compatible ranges, xxHash64, and zstd decompression. The compiler enables +# this as a unit whenever the `"bun"` module or the opt-in Bun global is used; +# unrelated auto-optimized binaries do not compile or link these backends. +bun-cli-utils = [ + "dep:node-semver", + "dep:toml", + "dep:unsafe-libyaml", + "dep:xxhash-rust", + "dep:zstd", +] # Cold-path diagnostic JSON serializers (~67 KB of code + the `serde_json` # pulled only by them, which dead-strips when unreferenced): GC cycle telemetry # (`PERRY_GC_DIAG`), typed-feedback trace dump (`PERRY_TYPED_FEEDBACK`), the v8 @@ -310,6 +321,11 @@ lazy_static.workspace = true # only on swc + perry-diagnostics — no cycle back into perry-runtime. perry-parser = { path = "../perry-parser", optional = true } perry-diagnostics = { path = "../perry-diagnostics", optional = true } +node-semver = { version = "2.2.0", optional = true } +toml = { workspace = true, optional = true } +unsafe-libyaml = { version = "0.2.11", optional = true } +xxhash-rust = { version = "0.8.15", features = ["xxh64"], optional = true } +zstd = { workspace = true, optional = true } # TC39 Temporal API (#4686). Pure-Rust date/time engine (the same one V8 # uses). We write no calendar/duration/timezone math — temporal_rs owns it; diff --git a/crates/perry-runtime/src/bun_compat/cli_utils.rs b/crates/perry-runtime/src/bun_compat/cli_utils.rs new file mode 100644 index 0000000000..3e75b4a25d --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/cli_utils.rs @@ -0,0 +1,1323 @@ +//! Bun's small CLI/data utility surface (#9600). +//! +//! Object-valued exports (`YAML`, `TOML`, `semver`, and `JSONL`) are ordinary +//! Perry objects containing native closures. This deliberately keeps their +//! nested method calls on the normal dynamic-property path, while the direct +//! exports use the native module call table. + +use super::{ + bool_value, boxed_str, is_string_value, is_undefined_or_null, key_ptr, object_field, + payload_bytes, promise_rejected, promise_value, value_to_string, +}; +use crate::closure::{js_closure_alloc, js_register_closure_arity, ClosureHeader}; +use crate::gc::{RootedValues, RuntimeHandle, RuntimeHandleScope}; +use crate::object::{js_object_alloc, js_object_set_field_by_name}; +use crate::string::js_string_from_bytes; +use crate::value::JSValue; +use std::cmp::Ordering; + +fn null_value() -> f64 { + f64::from_bits(JSValue::null().bits()) +} + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn error_value(message: &str) -> f64 { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_error_new_with_message(message); + f64::from_bits(JSValue::pointer(error as *const u8).bits()) +} + +fn syntax_error_value(message: &str) -> f64 { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_syntaxerror_new(message); + f64::from_bits(JSValue::pointer(error as *const u8).bits()) +} + +fn throw_type_error(message: &str) -> ! { + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_typeerror_new(message); + crate::exception::js_throw(f64::from_bits(JSValue::pointer(error as *const u8).bits())) +} + +fn number_arg(value: f64) -> Option { + let value = JSValue::from_bits(value.to_bits()); + if value.is_int32() { + Some(value.as_int32() as f64) + } else if value.is_number() { + Some(value.as_number()) + } else { + None + } +} + +fn closure1(name: &str, func: extern "C" fn(*const ClosureHeader, f64) -> f64) -> f64 { + js_register_closure_arity(func as *const u8, 1); + let closure = js_closure_alloc(func as *const u8, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, 1); + f64::from_bits(JSValue::pointer(closure as *const u8).bits()) +} + +fn closure2( + name: &str, + func: extern "C" fn(*const ClosureHeader, f64, f64) -> f64, + length: u32, +) -> f64 { + js_register_closure_arity(func as *const u8, 2); + let closure = js_closure_alloc(func as *const u8, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, length); + f64::from_bits(JSValue::pointer(closure as *const u8).bits()) +} + +fn closure3( + name: &str, + func: extern "C" fn(*const ClosureHeader, f64, f64, f64) -> f64, + length: u32, +) -> f64 { + js_register_closure_arity(func as *const u8, 3); + let closure = js_closure_alloc(func as *const u8, 0); + crate::object::set_bound_native_closure_name(closure, name); + crate::object::set_builtin_closure_length(closure as usize, length); + f64::from_bits(JSValue::pointer(closure as *const u8).bits()) +} + +fn rooted_object_ptr(handle: &RuntimeHandle<'_>) -> *mut crate::object::ObjectHeader { + JSValue::from_bits(handle.get_nanbox_f64().to_bits()) + .as_pointer::() as *mut _ +} + +/// Store a property while keeping both the target and value current if key +/// allocation triggers a moving collection. +fn set_rooted_field( + scope: &RuntimeHandleScope, + object: &RuntimeHandle<'_>, + key: &[u8], + value: f64, +) { + let value = scope.root_nanbox_f64(value); + let key = key_ptr(key); + js_object_set_field_by_name(rooted_object_ptr(object), key, value.get_nanbox_f64()); +} + +fn namespace_object(fields: &[(&[u8], f64)]) -> f64 { + let scope = RuntimeHandleScope::new(); + let object = scope.root_nanbox_f64(f64::from_bits( + JSValue::pointer(js_object_alloc(0, fields.len() as u32) as *const u8).bits(), + )); + for (key, value) in fields { + set_rooted_field(&scope, &object, key, *value); + } + object.get_nanbox_f64() +} + +// --------------------------------------------------------------------------- +// Object-valued exports +// --------------------------------------------------------------------------- + +extern "C" fn yaml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { + yaml_parse(input) +} + +extern "C" fn yaml_stringify_closure( + _closure: *const ClosureHeader, + input: f64, + replacer: f64, + space: f64, +) -> f64 { + yaml_stringify(input, replacer, space) +} + +pub fn js_bun_yaml() -> f64 { + namespace_object(&[ + (b"parse", closure1("parse", yaml_parse_closure)), + ( + b"stringify", + closure3("stringify", yaml_stringify_closure, 1), + ), + ]) +} + +extern "C" fn toml_parse_closure(_closure: *const ClosureHeader, input: f64) -> f64 { + let source = value_to_string(input); + // `Value::from_str` in toml 1.x parses a single TOML value expression; + // Bun.TOML.parse consumes a complete document, whose root is a table. + let parsed = match toml::from_str::(&source) { + Ok(parsed) => parsed, + Err(error) => crate::exception::js_throw(syntax_error_value(&format!( + "Failed to parse TOML: {error}" + ))), + }; + let json = match serde_json::to_string(&parsed) { + Ok(json) => json, + Err(error) => crate::exception::js_throw(syntax_error_value(&format!( + "Failed to convert TOML value: {error}" + ))), + }; + let source = js_string_from_bytes(json.as_ptr(), json.len() as u32); + match unsafe { crate::json::js_json_parse_result(source) } { + Ok(value) => f64::from_bits(value.bits()), + Err(error) => crate::exception::js_throw(error), + } +} + +pub fn js_bun_toml() -> f64 { + namespace_object(&[(b"parse", closure1("parse", toml_parse_closure))]) +} + +fn normalize_semver_version(input: &str) -> &str { + input + .trim() + .strip_prefix('v') + .or_else(|| input.trim().strip_prefix('=')) + .unwrap_or_else(|| input.trim()) +} + +extern "C" fn semver_order_closure(_closure: *const ClosureHeader, left: f64, right: f64) -> f64 { + let left_source = value_to_string(left); + let right_source = value_to_string(right); + let left = match node_semver::Version::parse(normalize_semver_version(&left_source)) { + Ok(version) => version, + Err(_) => crate::exception::js_throw(error_value(&format!( + "Invalid SemVer: {}", + left_source.trim() + ))), + }; + let right = match node_semver::Version::parse(normalize_semver_version(&right_source)) { + Ok(version) => version, + Err(_) => crate::exception::js_throw(error_value(&format!( + "Invalid SemVer: {}", + right_source.trim() + ))), + }; + match left.cmp(&right) { + Ordering::Less => -1.0, + Ordering::Equal => 0.0, + Ordering::Greater => 1.0, + } +} + +extern "C" fn semver_satisfies_closure( + _closure: *const ClosureHeader, + version: f64, + range: f64, +) -> f64 { + let version = value_to_string(version); + let range = value_to_string(range); + let Ok(version) = node_semver::Version::parse(normalize_semver_version(&version)) else { + return bool_value(false); + }; + let range = range.trim(); + if range.is_empty() || range == "*" || range.eq_ignore_ascii_case("latest") { + return bool_value(!version.is_prerelease()); + } + let satisfied = node_semver::Range::parse(range) + .map(|range| range.satisfies(&version)) + .unwrap_or(false); + bool_value(satisfied) +} + +pub fn js_bun_semver() -> f64 { + namespace_object(&[ + (b"order", closure2("order", semver_order_closure, 2)), + ( + b"satisfies", + closure2("satisfies", semver_satisfies_closure, 2), + ), + ]) +} + +extern "C" fn jsonl_parse_chunk_closure( + _closure: *const ClosureHeader, + input: f64, + start: f64, + end: f64, +) -> f64 { + jsonl_parse_chunk(input, start, end) +} + +pub fn js_bun_jsonl() -> f64 { + namespace_object(&[( + b"parseChunk", + closure3("parseChunk", jsonl_parse_chunk_closure, 1), + )]) +} + +// --------------------------------------------------------------------------- +// Equality, terminal strings, executable lookup +// --------------------------------------------------------------------------- + +#[no_mangle] +pub extern "C" fn js_bun_deep_equals(left: f64, right: f64, strict: f64) -> f64 { + if !is_undefined_or_null(strict) && crate::value::js_is_truthy(strict) != 0 { + crate::builtins::js_util_is_deep_strict_equal(left, right) + } else { + crate::builtins::js_util_is_deep_strict_equal_skip_prototype(left, right) + } +} + +fn strip_ansi_text(input: &str) -> String { + let chars: Vec = input.chars().collect(); + let mut out = String::with_capacity(input.len()); + let mut index = 0usize; + while index < chars.len() { + if chars[index] != '\u{1b}' { + out.push(chars[index]); + index += 1; + continue; + } + if index + 1 >= chars.len() { + break; + } + match chars[index + 1] { + '[' => { + index += 2; + while index < chars.len() { + let final_byte = chars[index] as u32; + index += 1; + if (0x40..=0x7e).contains(&final_byte) { + break; + } + } + } + ']' => { + index += 2; + while index < chars.len() { + if matches!(chars[index], '\u{7}' | '\u{9c}') { + index += 1; + break; + } + if chars[index] == '\u{1b}' + && index + 1 < chars.len() + && chars[index + 1] == '\\' + { + index += 2; + break; + } + index += 1; + } + } + _ => index += 1, + } + } + out +} + +#[no_mangle] +pub extern "C" fn js_bun_strip_ansi(input: f64) -> f64 { + boxed_str(strip_ansi_text(&value_to_string(input)).as_bytes()) +} + +fn visible_width(input: &str, ambiguous_is_narrow: bool) -> usize { + let cps: Vec = input.chars().map(u32::from).collect(); + super::bun_string_width(&cps, false, ambiguous_is_narrow) +} + +/// Split at display columns without cutting a CSI/OSC escape sequence. +fn hard_wrap_word(input: &str, columns: usize, ambiguous_is_narrow: bool) -> Vec { + if columns == 0 || visible_width(input, ambiguous_is_narrow) <= columns { + return vec![input.to_string()]; + } + let chars: Vec = input.chars().collect(); + let mut chunks = vec![String::new()]; + let mut width = 0usize; + let mut index = 0usize; + while index < chars.len() { + if chars[index] == '\u{1b}' { + let start = index; + index += 1; + if index < chars.len() && chars[index] == '[' { + index += 1; + while index < chars.len() { + let c = chars[index] as u32; + index += 1; + if (0x40..=0x7e).contains(&c) { + break; + } + } + } else if index < chars.len() && chars[index] == ']' { + index += 1; + while index < chars.len() { + let c = chars[index]; + index += 1; + if matches!(c, '\u{7}' | '\u{9c}') { + break; + } + if c == '\u{1b}' && index < chars.len() && chars[index] == '\\' { + index += 1; + break; + } + } + } + chunks.last_mut().unwrap().extend(&chars[start..index]); + continue; + } + let char_width = visible_width(&chars[index].to_string(), ambiguous_is_narrow); + if width > 0 && width + char_width > columns { + chunks.push(String::new()); + width = 0; + } + chunks.last_mut().unwrap().push(chars[index]); + width += char_width; + index += 1; + } + chunks +} + +fn wrap_word_paragraph( + input: &str, + columns: usize, + hard: bool, + trim: bool, + ambiguous_is_narrow: bool, +) -> String { + if input.is_empty() { + return String::new(); + } + if !trim { + let mut out = String::new(); + let mut width = 0usize; + for chunk in hard_wrap_word(input, columns, ambiguous_is_narrow) { + let chunk_width = visible_width(&chunk, ambiguous_is_narrow); + if width > 0 && width + chunk_width > columns { + out.push('\n'); + width = 0; + } + out.push_str(&chunk); + width += chunk_width; + } + return out; + } + + let words: Vec<&str> = input.split_whitespace().collect(); + let mut out = String::new(); + let mut line_width = 0usize; + for word in words { + let pieces = if hard { + hard_wrap_word(word, columns, ambiguous_is_narrow) + } else { + vec![word.to_string()] + }; + for (piece_index, piece) in pieces.into_iter().enumerate() { + let piece_width = visible_width(&piece, ambiguous_is_narrow); + let separator = usize::from(line_width > 0 && piece_index == 0); + if line_width > 0 && line_width + separator + piece_width > columns { + out.push('\n'); + line_width = 0; + } else if separator != 0 { + out.push(' '); + line_width += 1; + } + out.push_str(&piece); + line_width += piece_width; + } + } + out +} + +fn wrap_columns_paragraph( + input: &str, + columns: usize, + trim: bool, + ambiguous_is_narrow: bool, +) -> String { + let source = if trim { input.trim() } else { input }; + hard_wrap_word(source, columns, ambiguous_is_narrow).join("\n") +} + +#[no_mangle] +pub extern "C" fn js_bun_wrap_ansi(input: f64, columns: f64, options: f64) -> f64 { + let input = value_to_string(input); + let columns = number_arg(columns).unwrap_or(0.0).max(0.0) as usize; + if columns == 0 { + return boxed_str(input.as_bytes()); + } + let hard = + object_field(options, b"hard").is_some_and(|value| crate::value::js_is_truthy(value) != 0); + let trim = object_field(options, b"trim") + .map(|value| crate::value::js_is_truthy(value) != 0) + .unwrap_or(true); + let word_wrap = object_field(options, b"wordWrap") + .map(|value| crate::value::js_is_truthy(value) != 0) + .unwrap_or(true); + let ambiguous_is_narrow = object_field(options, b"ambiguousIsNarrow") + .map(|value| crate::value::js_is_truthy(value) != 0) + .unwrap_or(true); + let output = input + .split('\n') + .map(|paragraph| { + if word_wrap { + wrap_word_paragraph(paragraph, columns, hard, trim, ambiguous_is_narrow) + } else { + wrap_columns_paragraph(paragraph, columns, trim, ambiguous_is_narrow) + } + }) + .collect::>() + .join("\n"); + boxed_str(output.as_bytes()) +} + +#[cfg(unix)] +fn is_executable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(windows)] +fn is_executable(path: &std::path::Path) -> bool { + std::fs::metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) +} + +#[no_mangle] +pub extern "C" fn js_bun_which(command: f64, options: f64) -> f64 { + let command = value_to_string(command); + if command.is_empty() { + return null_value(); + } + let command_path = std::path::Path::new(&command); + if command_path.components().count() > 1 { + return if is_executable(command_path) { + boxed_str(command.as_bytes()) + } else { + null_value() + }; + } + + let path = object_field(options, b"PATH") + .or_else(|| object_field(options, b"path")) + .map(value_to_string) + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + #[cfg(windows)] + let extensions: Vec = std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) + .split(';') + .map(str::to_string) + .collect(); + for directory in std::env::split_paths(&path) { + let candidate = directory.join(&command); + if is_executable(&candidate) { + return boxed_str(candidate.to_string_lossy().as_bytes()); + } + #[cfg(windows)] + for extension in &extensions { + let candidate = directory.join(format!("{command}{extension}")); + if is_executable(&candidate) { + return boxed_str(candidate.to_string_lossy().as_bytes()); + } + } + } + null_value() +} + +// --------------------------------------------------------------------------- +// zstd, xxHash64, GC and heap snapshots +// --------------------------------------------------------------------------- + +fn zstd_decode(input: f64) -> Result { + let bytes = payload_bytes(input)?; + match zstd::stream::decode_all(bytes.as_slice()) { + Ok(decoded) => Ok(crate::node_submodules::consumers::bytes_to_buffer_value( + &decoded, + )), + Err(_) => Err(error_value("Decompression failed: InvalidZstdData")), + } +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress_sync(input: f64) -> f64 { + match zstd_decode(input) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + } +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress(input: f64) -> f64 { + match zstd_decode(input) { + Ok(value) => promise_value(value), + Err(error) => promise_rejected(error), + } +} + +extern "C" fn xxhash64_closure(_closure: *const ClosureHeader, input: f64, seed: f64) -> f64 { + let bytes = payload_bytes(input).unwrap_or_default(); + let hash = xxhash_rust::xxh64::xxh64(&bytes, super::hash_seed(seed)); + let bigint = crate::bigint::js_bigint_from_u64(hash); + crate::value::js_nanbox_bigint(bigint as i64) +} + +pub fn decorate_bun_hash(value: f64) -> f64 { + let scope = RuntimeHandleScope::new(); + let hash = scope.root_nanbox_f64(value); + let xxhash = scope.root_nanbox_f64(closure2("xxHash64", xxhash64_closure, 1)); + let raw = JSValue::from_bits(hash.get_nanbox_f64().to_bits()).as_pointer::() as usize; + crate::closure::closure_set_dynamic_prop(raw, "xxHash64", xxhash.get_nanbox_f64()); + hash.get_nanbox_f64() +} + +#[no_mangle] +pub extern "C" fn js_bun_gc(_full: f64) -> f64 { + crate::gc::js_gc_collect(); + undefined() +} + +#[no_mangle] +pub extern "C" fn js_bun_generate_heap_snapshot(format: f64, encoding: f64) -> f64 { + if is_undefined_or_null(format) { + throw_type_error( + "Bun.generateHeapSnapshot() uses JavaScriptCore's snapshot object; Perry supports the explicit 'v8' format", + ); + } + if value_to_string(format) != "v8" { + throw_type_error("Bun.generateHeapSnapshot format must be 'v8'"); + } + #[cfg(feature = "diagnostics")] + { + let json = crate::gc::gc_build_v8_heap_snapshot_json(); + if !is_undefined_or_null(encoding) && value_to_string(encoding) == "arraybuffer" { + return crate::node_submodules::consumers::bytes_to_array_buffer_value(json.as_bytes()); + } + if !is_undefined_or_null(encoding) && value_to_string(encoding) != "string" { + throw_type_error("Bun.generateHeapSnapshot encoding must be 'string' or 'arraybuffer'"); + } + boxed_str(json.as_bytes()) + } + #[cfg(not(feature = "diagnostics"))] + { + let _ = encoding; + throw_type_error("Heap snapshot diagnostics are not enabled in this Perry runtime") + } +} + +// --------------------------------------------------------------------------- +// Bun.JSONL.parseChunk +// --------------------------------------------------------------------------- + +fn jsonl_parse_chunk(input: f64, start: f64, end: f64) -> f64 { + let (bytes, string_input) = if is_string_value(input) { + (value_to_string(input).into_bytes(), true) + } else { + match payload_bytes(input) { + Ok(bytes) => (bytes, false), + Err(error) => crate::exception::js_throw(error), + } + }; + let mut cursor = number_arg(start) + .unwrap_or(0.0) + .max(0.0) + .min(bytes.len() as f64) as usize; + let limit = number_arg(end) + .unwrap_or(bytes.len() as f64) + .max(cursor as f64) + .min(bytes.len() as f64) as usize; + let initial = cursor; + let mut spans = Vec::<(usize, usize)>::new(); + let mut last_read = initial; + let mut done = true; + let mut syntax_error = false; + + while cursor < limit { + let line_end = bytes[cursor..limit] + .iter() + .position(|byte| *byte == b'\n') + .map(|offset| cursor + offset) + .unwrap_or(limit); + let mut content_start = cursor; + let mut content_end = line_end; + while content_start < content_end && bytes[content_start].is_ascii_whitespace() { + content_start += 1; + } + while content_end > content_start && bytes[content_end - 1].is_ascii_whitespace() { + content_end -= 1; + } + if content_start == content_end { + cursor = if line_end < limit { + line_end + 1 + } else { + limit + }; + continue; + } + + match serde_json::from_slice::(&bytes[content_start..content_end]) { + Ok(_) => { + spans.push((content_start, content_end)); + // Bun reports an absolute character/byte offset immediately + // after the final parsed JSON token, excluding the delimiter. + last_read = if string_input { + String::from_utf8_lossy(&bytes[..content_end]) + .encode_utf16() + .count() + } else { + content_end + }; + cursor = if line_end < limit { + line_end + 1 + } else { + limit + }; + } + Err(error) if error.is_eof() => { + done = false; + break; + } + Err(_) => { + done = false; + syntax_error = true; + break; + } + } + } + + let scope = RuntimeHandleScope::new(); + let values = crate::array::js_array_alloc_with_length(spans.len() as u32); + let values = + scope.root_nanbox_f64(f64::from_bits(JSValue::pointer(values as *const u8).bits())); + for (index, (start, end)) in spans.into_iter().enumerate() { + let source = js_string_from_bytes(bytes[start..end].as_ptr(), (end - start) as u32); + let value = match unsafe { crate::json::js_json_parse_result(source) } { + Ok(value) => f64::from_bits(value.bits()), + Err(error) => crate::exception::js_throw(error), + }; + let value = scope.root_nanbox_f64(value); + let array = JSValue::from_bits(values.get_nanbox_f64().to_bits()) + .as_pointer::(); + crate::array::js_array_set_f64(array as *mut _, index as u32, value.get_nanbox_f64()); + } + + let error = if syntax_error { + syntax_error_value("Failed to parse JSONL") + } else { + null_value() + }; + let error = scope.root_nanbox_f64(error); + let result = scope.root_nanbox_f64(f64::from_bits( + JSValue::pointer(js_object_alloc(0, 4) as *const u8).bits(), + )); + set_rooted_field(&scope, &result, b"values", values.get_nanbox_f64()); + set_rooted_field(&scope, &result, b"read", last_read as f64); + set_rooted_field(&scope, &result, b"done", bool_value(done)); + set_rooted_field(&scope, &result, b"error", error.get_nanbox_f64()); + result.get_nanbox_f64() +} + +// --------------------------------------------------------------------------- +// Bun.YAML.parse — libyaml node graph -> rooted Perry graph +// --------------------------------------------------------------------------- + +unsafe fn yaml_scalar_bytes<'a>(node: *const unsafe_libyaml::yaml_node_t) -> &'a [u8] { + let scalar = (*node).data.scalar; + std::slice::from_raw_parts(scalar.value, scalar.length as usize) +} + +unsafe fn yaml_scalar_text(node: *const unsafe_libyaml::yaml_node_t) -> String { + String::from_utf8_lossy(yaml_scalar_bytes(node)).into_owned() +} + +fn yaml_plain_number(text: &str) -> Option { + let compact = text.replace('_', ""); + let (negative, unsigned) = compact + .strip_prefix('-') + .map(|rest| (true, rest)) + .or_else(|| compact.strip_prefix('+').map(|rest| (false, rest))) + .unwrap_or((false, compact.as_str())); + let integer = if let Some(value) = unsigned.strip_prefix("0x") { + i128::from_str_radix(value, 16).ok() + } else if let Some(value) = unsigned.strip_prefix("0o") { + i128::from_str_radix(value, 8).ok() + } else if let Some(value) = unsigned.strip_prefix("0b") { + i128::from_str_radix(value, 2).ok() + } else if !unsigned.contains(['.', 'e', 'E']) { + unsigned.parse::().ok() + } else { + None + }; + if let Some(integer) = integer { + let value = integer as f64; + return Some(if negative { -value } else { value }); + } + match compact.to_ascii_lowercase().as_str() { + ".inf" | "+.inf" => Some(f64::INFINITY), + "-.inf" => Some(f64::NEG_INFINITY), + ".nan" | "+.nan" | "-.nan" => Some(f64::NAN), + _ => compact.parse::().ok(), + } +} + +unsafe fn yaml_scalar_to_js(node: *const unsafe_libyaml::yaml_node_t, source: &[u8]) -> f64 { + let scalar = (*node).data.scalar; + let text = yaml_scalar_text(node); + let start = ((*node).start_mark.index as usize).min(source.len()); + let end = ((*node).end_mark.index as usize).min(source.len()); + let spelling = String::from_utf8_lossy(&source[start..end]); + let explicitly_string = spelling.trim_start().starts_with("!!str") + || spelling + .trim_start() + .starts_with("!"); + let plain = scalar.style == unsafe_libyaml::YAML_PLAIN_SCALAR_STYLE; + if plain && !explicitly_string { + if text.is_empty() || text == "~" || text.eq_ignore_ascii_case("null") { + return null_value(); + } + if text.eq_ignore_ascii_case("true") { + return bool_value(true); + } + if text.eq_ignore_ascii_case("false") { + return bool_value(false); + } + if let Some(number) = yaml_plain_number(&text) { + return number; + } + } + boxed_str(text.as_bytes()) +} + +unsafe fn yaml_node_count(document: *const unsafe_libyaml::yaml_document_t) -> usize { + (*document).nodes.top.offset_from((*document).nodes.start) as usize +} + +unsafe fn yaml_node( + document: *const unsafe_libyaml::yaml_document_t, + id: i32, +) -> *const unsafe_libyaml::yaml_node_t { + (*document).nodes.start.add(id.saturating_sub(1) as usize) +} + +unsafe fn yaml_mapping_key( + document: *const unsafe_libyaml::yaml_document_t, + roots: &RootedValues<'_>, + id: i32, +) -> String { + let node = yaml_node(document, id); + if (*node).type_ == unsafe_libyaml::YAML_SCALAR_NODE { + yaml_scalar_text(node) + } else { + value_to_string(roots.get(id.saturating_sub(1) as usize)) + } +} + +unsafe fn yaml_set_mapping_pair( + scope: &RuntimeHandleScope, + document: *const unsafe_libyaml::yaml_document_t, + roots: &RootedValues<'_>, + target_index: usize, + key_id: i32, + value_id: i32, +) { + if key_id <= 0 || value_id <= 0 { + return; + } + let key = yaml_mapping_key(document, roots, key_id); + let key_ptr = key_ptr(key.as_bytes()); + let target = JSValue::from_bits(roots.get(target_index).to_bits()) + .as_pointer::() as *mut _; + let value = scope.root_nanbox_f64(roots.get(value_id as usize - 1)); + js_object_set_field_by_name(target, key_ptr, value.get_nanbox_f64()); +} + +unsafe fn yaml_apply_merge( + scope: &RuntimeHandleScope, + document: *const unsafe_libyaml::yaml_document_t, + roots: &RootedValues<'_>, + target_index: usize, + source_id: i32, + depth: usize, +) { + if source_id <= 0 || depth > 64 { + return; + } + let source = yaml_node(document, source_id); + if (*source).type_ == unsafe_libyaml::YAML_MAPPING_NODE { + let pairs = (*source).data.mapping.pairs; + let count = pairs.top.offset_from(pairs.start) as usize; + for index in 0..count { + let pair = *pairs.start.add(index); + if yaml_mapping_key(document, roots, pair.key) != "<<" { + yaml_set_mapping_pair(scope, document, roots, target_index, pair.key, pair.value); + } + } + } else if (*source).type_ == unsafe_libyaml::YAML_SEQUENCE_NODE { + let items = (*source).data.sequence.items; + let count = items.top.offset_from(items.start) as usize; + // Earlier merge sources have precedence in YAML. Apply in reverse so + // their values are the final ones before explicit keys overwrite them. + for index in (0..count).rev() { + yaml_apply_merge( + scope, + document, + roots, + target_index, + *items.start.add(index), + depth + 1, + ); + } + } +} + +unsafe fn yaml_document_to_js( + document: *mut unsafe_libyaml::yaml_document_t, + source: &[u8], + scope: &RuntimeHandleScope, +) -> f64 { + let count = yaml_node_count(document); + let mut roots = RootedValues::with_capacity(scope, count); + for index in 0..count { + let node = (*document).nodes.start.add(index); + let value = if (*node).type_ == unsafe_libyaml::YAML_SCALAR_NODE { + yaml_scalar_to_js(node, source) + } else if (*node).type_ == unsafe_libyaml::YAML_SEQUENCE_NODE { + let items = (*node).data.sequence.items; + let length = items.top.offset_from(items.start).max(0) as u32; + f64::from_bits( + JSValue::pointer(crate::array::js_array_alloc_with_length(length) as *const u8) + .bits(), + ) + } else if (*node).type_ == unsafe_libyaml::YAML_MAPPING_NODE { + let pairs = (*node).data.mapping.pairs; + let length = pairs.top.offset_from(pairs.start).max(0) as u32; + f64::from_bits(JSValue::pointer(js_object_alloc(0, length) as *const u8).bits()) + } else { + null_value() + }; + roots.push(value); + } + + for index in 0..count { + let node = (*document).nodes.start.add(index); + if (*node).type_ == unsafe_libyaml::YAML_SEQUENCE_NODE { + let items = (*node).data.sequence.items; + let length = items.top.offset_from(items.start).max(0) as usize; + for item_index in 0..length { + let child_id = *items.start.add(item_index); + if child_id <= 0 { + continue; + } + let child = scope.root_nanbox_f64(roots.get(child_id as usize - 1)); + let target = JSValue::from_bits(roots.get(index).to_bits()) + .as_pointer::() + as *mut _; + crate::array::js_array_set_f64(target, item_index as u32, child.get_nanbox_f64()); + } + } else if (*node).type_ == unsafe_libyaml::YAML_MAPPING_NODE { + let pairs = (*node).data.mapping.pairs; + let length = pairs.top.offset_from(pairs.start).max(0) as usize; + for pair_index in 0..length { + let pair = *pairs.start.add(pair_index); + if yaml_mapping_key(document, &roots, pair.key) == "<<" { + yaml_apply_merge(scope, document, &roots, index, pair.value, 0); + } + } + for pair_index in 0..length { + let pair = *pairs.start.add(pair_index); + if yaml_mapping_key(document, &roots, pair.key) != "<<" { + yaml_set_mapping_pair(scope, document, &roots, index, pair.key, pair.value); + } + } + } + } + roots.get(0) +} + +fn yaml_parse(input: f64) -> f64 { + let source = value_to_string(input).into_bytes(); + unsafe { + let mut parser = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_parser_initialize(parser.as_mut_ptr()).fail { + crate::exception::js_throw(syntax_error_value("Failed to initialize YAML parser")); + } + let mut parser = parser.assume_init(); + unsafe_libyaml::yaml_parser_set_input_string( + &mut parser, + source.as_ptr(), + source.len() as u64, + ); + let scope = RuntimeHandleScope::new(); + let mut documents = RootedValues::new(&scope); + loop { + let mut document = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_parser_load(&mut parser, document.as_mut_ptr()).fail { + let problem = if parser.problem.is_null() { + "invalid YAML".to_string() + } else { + std::ffi::CStr::from_ptr(parser.problem) + .to_string_lossy() + .into_owned() + }; + let line = parser.problem_mark.line + 1; + let column = parser.problem_mark.column + 1; + unsafe_libyaml::yaml_parser_delete(&mut parser); + crate::exception::js_throw(syntax_error_value(&format!( + "Failed to parse YAML at {line}:{column}: {problem}" + ))); + } + let mut document = document.assume_init(); + let root = unsafe_libyaml::yaml_document_get_root_node(&mut document); + if root.is_null() { + unsafe_libyaml::yaml_document_delete(&mut document); + break; + } + let value = yaml_document_to_js(&mut document, &source, &scope); + documents.push(value); + unsafe_libyaml::yaml_document_delete(&mut document); + } + unsafe_libyaml::yaml_parser_delete(&mut parser); + + if documents.is_empty() { + return null_value(); + } + if documents.len() == 1 { + return documents.get(0); + } + let output = scope.root_nanbox_f64(f64::from_bits( + JSValue::pointer( + crate::array::js_array_alloc_with_length(documents.len() as u32) as *const u8, + ) + .bits(), + )); + for index in 0..documents.len() { + let value = scope.root_nanbox_f64(documents.get(index)); + let array = JSValue::from_bits(output.get_nanbox_f64().to_bits()) + .as_pointer::() as *mut _; + crate::array::js_array_set_f64(array, index as u32, value.get_nanbox_f64()); + } + output.get_nanbox_f64() + } +} + +// --------------------------------------------------------------------------- +// Bun.YAML.stringify — rooted Perry graph -> libyaml document graph +// --------------------------------------------------------------------------- + +const YAML_STR_TAG: &[u8] = b"tag:yaml.org,2002:str\0"; +const YAML_INT_TAG: &[u8] = b"tag:yaml.org,2002:int\0"; +const YAML_FLOAT_TAG: &[u8] = b"tag:yaml.org,2002:float\0"; +const YAML_BOOL_TAG: &[u8] = b"tag:yaml.org,2002:bool\0"; +const YAML_NULL_TAG: &[u8] = b"tag:yaml.org,2002:null\0"; + +unsafe fn yaml_add_scalar( + document: *mut unsafe_libyaml::yaml_document_t, + tag: &[u8], + text: &str, +) -> i32 { + unsafe_libyaml::yaml_document_add_scalar( + document, + tag.as_ptr(), + text.as_ptr(), + text.len() as i32, + unsafe_libyaml::YAML_ANY_SCALAR_STYLE, + ) +} + +fn yaml_number_text(value: f64) -> String { + if value.is_nan() { + return ".nan".to_string(); + } + if value == f64::INFINITY { + return ".inf".to_string(); + } + if value == f64::NEG_INFINITY { + return "-.inf".to_string(); + } + if value == 0.0 && value.is_sign_negative() { + return "-0".to_string(); + } + let mut buffer = ryu::Buffer::new(); + buffer.format(value).to_string() +} + +unsafe fn yaml_add_js_value( + document: *mut unsafe_libyaml::yaml_document_t, + scope: &RuntimeHandleScope, + roots: &mut RootedValues<'_>, + value_index: usize, + memo: &mut Vec<(usize, i32)>, + flow: bool, + depth: usize, +) -> i32 { + if depth > 1_000 { + throw_type_error("YAML.stringify: value nested deeper than 1000 levels"); + } + let raw = roots.get(value_index); + let value = JSValue::from_bits(raw.to_bits()); + if value.is_undefined() { + return 0; + } + if value.is_null() { + return yaml_add_scalar(document, YAML_NULL_TAG, "null"); + } + if value.is_bool() { + return yaml_add_scalar( + document, + YAML_BOOL_TAG, + if value.as_bool() { "true" } else { "false" }, + ); + } + if value.is_int32() { + return yaml_add_scalar(document, YAML_INT_TAG, &value.as_int32().to_string()); + } + if value.is_number() { + let number = value.as_number(); + let tag = if number.is_finite() && number.fract() == 0.0 { + YAML_INT_TAG + } else { + YAML_FLOAT_TAG + }; + return yaml_add_scalar(document, tag, &yaml_number_text(number)); + } + if value.is_string() || value.is_short_string() { + return yaml_add_scalar(document, YAML_STR_TAG, &value_to_string(raw)); + } + if crate::value::js_nanbox_is_bigint(raw) != 0 { + return yaml_add_scalar(document, YAML_INT_TAG, &value_to_string(raw)); + } + if !value.is_pointer() { + return 0; + } + let address = value.as_pointer::() as usize; + if !crate::json::ptr_is_tracked_heap_object(address as *const u8) { + return 0; + } + let Some(header) = crate::value::addr_class::try_read_gc_header(address) else { + return 0; + }; + if !matches!( + header.obj_type, + crate::gc::GC_TYPE_ARRAY | crate::gc::GC_TYPE_OBJECT + ) { + return 0; + } + for (memo_index, node_id) in memo.iter().copied() { + if roots.get(memo_index).to_bits() == roots.get(value_index).to_bits() { + return node_id; + } + } + + if header.obj_type == crate::gc::GC_TYPE_ARRAY { + let style = if flow { + unsafe_libyaml::YAML_FLOW_SEQUENCE_STYLE + } else { + unsafe_libyaml::YAML_BLOCK_SEQUENCE_STYLE + }; + let node_id = unsafe_libyaml::yaml_document_add_sequence(document, std::ptr::null(), style); + if node_id == 0 { + return 0; + } + memo.push((value_index, node_id)); + let array = roots.get(value_index); + let array = JSValue::from_bits(array.to_bits()).as_pointer::(); + let length = crate::array::js_array_length(array); + for index in 0..length { + let array = roots.get(value_index); + let array = + JSValue::from_bits(array.to_bits()).as_pointer::(); + let child = crate::array::js_array_get_f64(array, index); + roots.push(child); + let child_index = roots.len() - 1; + let child_id = + yaml_add_js_value(document, scope, roots, child_index, memo, flow, depth + 1); + if child_id != 0 { + let _ = + unsafe_libyaml::yaml_document_append_sequence_item(document, node_id, child_id); + } + } + return node_id; + } + + let style = if flow { + unsafe_libyaml::YAML_FLOW_MAPPING_STYLE + } else { + unsafe_libyaml::YAML_BLOCK_MAPPING_STYLE + }; + let node_id = unsafe_libyaml::yaml_document_add_mapping(document, std::ptr::null(), style); + if node_id == 0 { + return 0; + } + memo.push((value_index, node_id)); + let object = roots.get(value_index); + let object = JSValue::from_bits(object.to_bits()).as_pointer::(); + let keys = crate::object::js_object_keys(object); + roots.push(f64::from_bits(JSValue::pointer(keys as *const u8).bits())); + let keys_index = roots.len() - 1; + let keys = JSValue::from_bits(roots.get(keys_index).to_bits()) + .as_pointer::(); + let length = crate::array::js_array_length(keys); + for index in 0..length { + let keys = JSValue::from_bits(roots.get(keys_index).to_bits()) + .as_pointer::(); + let key_value = crate::array::js_array_get_f64(keys, index); + let key = value_to_string(key_value); + let lookup_key = key_ptr(key.as_bytes()); + let object = JSValue::from_bits(roots.get(value_index).to_bits()) + .as_pointer::(); + let child = crate::object::js_object_get_field_by_name_f64(object, lookup_key); + roots.push(child); + let child_index = roots.len() - 1; + let child_id = + yaml_add_js_value(document, scope, roots, child_index, memo, flow, depth + 1); + if child_id == 0 { + continue; + } + let key_id = yaml_add_scalar(document, YAML_STR_TAG, &key); + if key_id != 0 { + let _ = unsafe_libyaml::yaml_document_append_mapping_pair( + document, node_id, key_id, child_id, + ); + } + } + let _ = scope; + node_id +} + +unsafe fn yaml_vec_write( + data: *mut libc::c_void, + buffer: *mut libc::c_uchar, + size: u64, +) -> libc::c_int { + let output = &mut *(data as *mut Vec); + output.extend_from_slice(std::slice::from_raw_parts(buffer, size as usize)); + 1 +} + +fn compact_flow_yaml(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut quote = None; + let mut escaped = false; + while let Some(character) = chars.next() { + if let Some(active) = quote { + out.push(character); + if active == '"' && character == '\\' && !escaped { + escaped = true; + continue; + } + if character == active && !escaped { + quote = None; + } + escaped = false; + continue; + } + if matches!(character, '\'' | '"') { + quote = Some(character); + out.push(character); + continue; + } + out.push(character); + if matches!(character, ',' | ':') { + while chars.peek() == Some(&' ') { + chars.next(); + } + } + } + out +} + +fn yaml_stringify(input: f64, replacer: f64, space: f64) -> f64 { + let _ = replacer; + if JSValue::from_bits(input.to_bits()).is_undefined() { + return undefined(); + } + let indent = if is_undefined_or_null(space) { + 0 + } else if is_string_value(space) { + value_to_string(space).chars().count().clamp(1, 9) as i32 + } else { + number_arg(space).unwrap_or(0.0).clamp(0.0, 9.0) as i32 + }; + let flow = indent == 0; + + unsafe { + let mut document = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_document_initialize( + document.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + true, + true, + ) + .fail + { + throw_type_error("Failed to initialize YAML document"); + } + let mut document = document.assume_init(); + let scope = RuntimeHandleScope::new(); + let mut roots = RootedValues::new(&scope); + roots.push(input); + let root_id = yaml_add_js_value( + &mut document, + &scope, + &mut roots, + 0, + &mut Vec::new(), + flow, + 0, + ); + if root_id == 0 { + unsafe_libyaml::yaml_document_delete(&mut document); + return undefined(); + } + + let mut emitter = std::mem::MaybeUninit::::uninit(); + if unsafe_libyaml::yaml_emitter_initialize(emitter.as_mut_ptr()).fail { + unsafe_libyaml::yaml_document_delete(&mut document); + throw_type_error("Failed to initialize YAML emitter"); + } + let mut emitter = emitter.assume_init(); + let mut output = Vec::::new(); + unsafe_libyaml::yaml_emitter_set_output( + &mut emitter, + yaml_vec_write, + &mut output as *mut Vec as *mut libc::c_void, + ); + unsafe_libyaml::yaml_emitter_set_unicode(&mut emitter, true); + unsafe_libyaml::yaml_emitter_set_width(&mut emitter, -1); + if indent > 0 { + unsafe_libyaml::yaml_emitter_set_indent(&mut emitter, indent.max(2)); + } + if unsafe_libyaml::yaml_emitter_dump(&mut emitter, &mut document).fail { + unsafe_libyaml::yaml_emitter_delete(&mut emitter); + throw_type_error("Failed to emit YAML"); + } + let _ = unsafe_libyaml::yaml_emitter_close(&mut emitter); + unsafe_libyaml::yaml_emitter_delete(&mut emitter); + + let mut output = String::from_utf8_lossy(&output).into_owned(); + if let Some(without_end) = output.strip_suffix("...\n") { + output = without_end.to_string(); + } + while output.ends_with('\n') { + output.pop(); + } + if flow { + output = compact_flow_yaml(&output); + } + boxed_str(output.as_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_ansi_handles_csi_and_osc() { + assert_eq!(strip_ansi_text("\x1b[31ma\x1b[0mb"), "ab"); + assert_eq!( + strip_ansi_text("\x1b]8;;https://x\x1b\\link\x1b]8;;\x07"), + "link" + ); + } + + #[test] + fn flow_compaction_does_not_touch_quoted_spaces() { + assert_eq!(compact_flow_yaml("{a: 1, b: 'x, y'}"), "{a:1,b:'x, y'}"); + } +} diff --git a/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs new file mode 100644 index 0000000000..8cebf7a233 --- /dev/null +++ b/crates/perry-runtime/src/bun_compat/cli_utils_stub.rs @@ -0,0 +1,77 @@ +//! Link-safe stubs for auto-optimized runtimes that do not include #9600's +//! Bun CLI utility backends. The compiler turns `bun-cli-utils` on whenever a +//! Bun import/global is reachable, so these are defensive diagnostics only. + +use crate::string::js_string_from_bytes; +use crate::value::JSValue; + +fn undefined() -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn feature_disabled() -> ! { + let message = b"Bun CLI utilities are not enabled in this optimized Perry runtime"; + let message = js_string_from_bytes(message.as_ptr(), message.len() as u32); + let error = crate::error::js_error_new_with_message(message); + crate::exception::js_throw(f64::from_bits(JSValue::pointer(error as *const u8).bits())) +} + +pub fn js_bun_yaml() -> f64 { + undefined() +} + +pub fn js_bun_toml() -> f64 { + undefined() +} + +pub fn js_bun_semver() -> f64 { + undefined() +} + +pub fn js_bun_jsonl() -> f64 { + undefined() +} + +pub fn decorate_bun_hash(value: f64) -> f64 { + value +} + +#[no_mangle] +pub extern "C" fn js_bun_deep_equals(_left: f64, _right: f64, _strict: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_strip_ansi(_input: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_wrap_ansi(_input: f64, _columns: f64, _options: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_which(_command: f64, _options: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress(_input: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_zstd_decompress_sync(_input: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_gc(_full: f64) -> f64 { + feature_disabled() +} + +#[no_mangle] +pub extern "C" fn js_bun_generate_heap_snapshot(_format: f64, _encoding: f64) -> f64 { + feature_disabled() +} diff --git a/crates/perry-runtime/src/bun_compat/mod.rs b/crates/perry-runtime/src/bun_compat/mod.rs index 545d27af23..97071cc840 100644 --- a/crates/perry-runtime/src/bun_compat/mod.rs +++ b/crates/perry-runtime/src/bun_compat/mod.rs @@ -20,6 +20,10 @@ //! - `Bun.hash` is Zig-std Wyhash (see `wyhash.rs`) returning a BigInt, so //! `.toString(16)` cache keys match bun-run installs. +#[cfg(feature = "bun-cli-utils")] +mod cli_utils; +#[cfg(not(feature = "bun-cli-utils"))] +mod cli_utils_stub; mod glob; mod string_width; mod width_tables; @@ -36,6 +40,10 @@ use crate::string::{js_string_from_bytes, StringHeader}; use crate::value::{js_jsvalue_to_string, JSValue}; use std::io::{Read, Write}; +#[cfg(feature = "bun-cli-utils")] +pub use cli_utils::*; +#[cfg(not(feature = "bun-cli-utils"))] +pub use cli_utils_stub::*; pub use glob::js_bun_glob_new; pub use string_width::bun_string_width; pub use wyhash::wyhash; diff --git a/crates/perry-runtime/src/node_submodules/mod.rs b/crates/perry-runtime/src/node_submodules/mod.rs index e58f3f120c..3c6832cfc5 100644 --- a/crates/perry-runtime/src/node_submodules/mod.rs +++ b/crates/perry-runtime/src/node_submodules/mod.rs @@ -109,7 +109,7 @@ macro_rules! thunk { } pub(crate) mod blob; -mod consumers; +pub(crate) mod consumers; mod fs_promises; mod hono_jsx; mod stream_promises; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 54d679c35c..a8e7095588 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1007,6 +1007,15 @@ unsafe fn native_module_property_by_name_impl( "stdin" => return crate::bun_compat::js_bun_stdin(), "stdout" => return crate::bun_compat::js_bun_stdout(), "stderr" => return crate::bun_compat::js_bun_stderr(), + "YAML" => return crate::bun_compat::js_bun_yaml(), + "TOML" => return crate::bun_compat::js_bun_toml(), + "semver" => return crate::bun_compat::js_bun_semver(), + "JSONL" => return crate::bun_compat::js_bun_jsonl(), + "hash" => { + return crate::bun_compat::decorate_bun_hash(bound_native_callable_export_value( + "bun", "hash", + )) + } "version" => return native_string_value(env!("CARGO_PKG_VERSION")), "isStandaloneExecutable" => return crate::embedded::is_standalone_executable_value(), _ => {} @@ -1196,7 +1205,12 @@ pub extern "C" fn js_native_module_bind_method( { return bound; } - return bound_native_callable_export_value(&module_name, property_name); + let value = bound_native_callable_export_value(&module_name, property_name); + return if module_name == "bun" && property_name == "hash" { + crate::bun_compat::decorate_bun_hash(value) + } else { + value + }; } // Try V8 JS runtime fallback for unknown properties (e.g., ethers.Contract) @@ -1841,9 +1855,13 @@ unsafe fn vt_get_own_field( if let Some(bound) = instance_bound_perf_method(&module_name, property_name, nb_ptr) { return Some(JSValue::from_bits(bound.to_bits())); } - return Some(JSValue::from_bits( - bound_native_callable_export_value(&module_name, property_name).to_bits(), - )); + let value = bound_native_callable_export_value(&module_name, property_name); + let value = if module_name == "bun" && property_name == "hash" { + crate::bun_compat::decorate_bun_hash(value) + } else { + value + }; + return Some(JSValue::from_bits(value.to_bits())); } // Object-valued exports (e.g. `perf_hooks.performance` / `.constants`) are // resolved by the shared per-property dispatch but are not covered by the diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index 20933f33cd..839b553bf5 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -3,10 +3,13 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option { match (module, prop) { // Bun global/module surface (#9599). - ("bun", "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth") => { - Some(1) - } - ("bun", "write") => Some(2), + ( + "bun", + "Glob" | "file" | "fileURLToPath" | "gc" | "hash" | "pathToFileURL" | "stringWidth" + | "stripANSI" | "which" | "zstdDecompress" | "zstdDecompressSync", + ) => Some(1), + ("bun", "deepEquals" | "generateHeapSnapshot" | "write") => Some(2), + ("bun", "wrapAnsi") => Some(3), // bun:ffi (#6562). ("bun:ffi", "dlopen") => Some(2), ("bun:ffi", "ptr" | "CString" | "CFunction" | "linkSymbols") => Some(1), @@ -287,12 +290,20 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ "bun", &[ ("Glob", 1), + ("deepEquals", 2), ("file", 1), ("fileURLToPath", 1), + ("gc", 1), + ("generateHeapSnapshot", 2), ("hash", 1), ("pathToFileURL", 1), ("stringWidth", 1), + ("stripANSI", 1), + ("which", 1), + ("wrapAnsi", 3), ("write", 2), + ("zstdDecompress", 1), + ("zstdDecompressSync", 1), ], ), ( diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 72388da1d9..2b0878a33a 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -38,7 +38,21 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st if module == "bun" && matches!( prop, - "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth" | "write" + "Glob" + | "deepEquals" + | "file" + | "fileURLToPath" + | "gc" + | "generateHeapSnapshot" + | "hash" + | "pathToFileURL" + | "stringWidth" + | "stripANSI" + | "which" + | "wrapAnsi" + | "write" + | "zstdDecompress" + | "zstdDecompressSync" ) { return true; diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 833a53f044..c19d4ea44d 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -85,12 +85,20 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "bun", &[ "Glob", + "deepEquals", "file", "fileURLToPath", + "gc", + "generateHeapSnapshot", "hash", "pathToFileURL", "stringWidth", + "stripANSI", + "which", + "wrapAnsi", "write", + "zstdDecompress", + "zstdDecompressSync", ], ), ( diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index 3e7757628e..e2aa8fe3b8 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1630,17 +1630,29 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati // "bun"` share this one enumerable native-module surface. "bun" => Some(&[ b"Glob", + b"JSONL", + b"TOML", + b"YAML", + b"deepEquals", b"file", b"fileURLToPath", + b"gc", + b"generateHeapSnapshot", b"hash", b"isStandaloneExecutable", b"pathToFileURL", + b"semver", b"stderr", b"stdin", b"stdout", b"stringWidth", + b"stripANSI", b"version", + b"which", + b"wrapAnsi", b"write", + b"zstdDecompress", + b"zstdDecompressSync", ]), // bun:ffi (#6562) — stage-1 surface plus the declared-but-throwing // exports (their reads resolve to callables that raise the stage-1 diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index 3eaeed3cb3..5c29981c4f 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -274,6 +274,16 @@ pub(crate) unsafe fn nm_dispatch_bun(ctx: &NmCtx, module_name: &str, method_name match (module_name, method_name) { ("bun", "stringWidth") => crate::bun_compat::js_bun_string_width(arg(0), arg(1)), ("bun", "hash") => crate::bun_compat::js_bun_hash(arg(0), arg(1)), + ("bun", "deepEquals") => crate::bun_compat::js_bun_deep_equals(arg(0), arg(1), arg(2)), + ("bun", "stripANSI") => crate::bun_compat::js_bun_strip_ansi(arg(0)), + ("bun", "wrapAnsi") => crate::bun_compat::js_bun_wrap_ansi(arg(0), arg(1), arg(2)), + ("bun", "which") => crate::bun_compat::js_bun_which(arg(0), arg(1)), + ("bun", "zstdDecompress") => crate::bun_compat::js_bun_zstd_decompress(arg(0)), + ("bun", "zstdDecompressSync") => crate::bun_compat::js_bun_zstd_decompress_sync(arg(0)), + ("bun", "gc") => crate::bun_compat::js_bun_gc(arg(0)), + ("bun", "generateHeapSnapshot") => { + crate::bun_compat::js_bun_generate_heap_snapshot(arg(0), arg(1)) + } ("bun", "file") => crate::bun_compat::js_bun_file(arg(0)), ("bun", "write") => crate::bun_compat::js_bun_write(arg(0), arg(1)), ("bun", "Glob") => crate::bun_compat::js_bun_glob_new(arg(0)), diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a971228f86..86fda24780 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1267,6 +1267,12 @@ fn collect_module_one( // panic = "unwind" when this is set. ctx.needs_thread = true; } + // Runtime-only Bun imports do not satisfy `requires_stdlib`, but + // #9600's parser/compression backends are an optional runtime + // feature and therefore still need an auto-optimize marker. + if import.source == "bun" { + ctx.native_module_imports.insert("bun".to_string()); + } if perry_hir::requires_stdlib(&import.source) { ctx.needs_stdlib = true; // Track for `--minimal-stdlib` feature computation. Strip diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index c281ef1ec7..a62db7c938 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -333,6 +333,12 @@ pub(super) fn detect_optional_feature_usage( "{:?}{:?}{:?}", &hir_module.init, &hir_module.functions, &hir_module.classes ); + // Bare `Bun.*` calls have no import declaration. Lowering gives them + // the same `module: "bun"` marker as imported calls; retain it so the + // optimized runtime includes #9600's utility backends. + if hir_debug.contains("module: \"bun\"") || hir_debug.contains("NativeModuleRef(\"bun\")") { + ctx.native_module_imports.insert("bun".to_string()); + } for native_module in [ "lru-cache", "big.js", @@ -522,6 +528,7 @@ pub(super) fn detect_optional_feature_usage( ); if hir_debug.contains("method: \"getHeapSnapshot\"") || hir_debug.contains("method: \"writeHeapSnapshot\"") + || hir_debug.contains("method: \"generateHeapSnapshot\"") || hir_debug.contains("property: \"report\"") { ctx.uses_diagnostics = true; diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index e5f0a76eab..6e34d3783c 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -221,6 +221,9 @@ pub(crate) fn auto_optimized_cross_features( if !ctx.native_addons.is_empty() { cross_features.push("perry-runtime/node-api-host".to_string()); } + if ctx.bun_platform || ctx.native_module_imports.contains("bun") { + cross_features.push("perry-runtime/bun-cli-utils".to_string()); + } // Binary-size feature gating (kept in sync with the inline list on `main`): // each engine/table is linked only when the program actually uses it. if ctx.uses_regex { diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 88ef05726c..8743028e78 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1176,6 +1176,32 @@ fn auto_optimize_always_includes_keepalive_anchors() { ); } +#[test] +fn bun_usage_enables_cli_utility_runtime_pack() { + let dir = tempfile::tempdir().expect("tempdir"); + let empty_features: std::collections::BTreeSet<&'static str> = + std::collections::BTreeSet::new(); + let mut imported = CompilationContext::new(dir.path().to_path_buf()); + imported.native_module_imports.insert("bun".to_string()); + let imported_features = auto_optimized_cross_features(&imported, &empty_features, &[]); + assert!( + imported_features + .iter() + .any(|feature| feature == "perry-runtime/bun-cli-utils"), + "a bun import must retain #9600's runtime backends: {imported_features:?}" + ); + + let mut platform = CompilationContext::new(dir.path().to_path_buf()); + platform.bun_platform = true; + let platform_features = auto_optimized_cross_features(&platform, &empty_features, &[]); + assert!( + platform_features + .iter() + .any(|feature| feature == "perry-runtime/bun-cli-utils"), + "--platform bun must retain #9600's runtime backends: {platform_features:?}" + ); +} + #[test] fn data_url_dynamic_import_enables_dyn_eval_and_changes_cache_key() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/perry/tests/issue_9600_bun_cli_utilities.rs b/crates/perry/tests/issue_9600_bun_cli_utilities.rs new file mode 100644 index 0000000000..da81d29ae0 --- /dev/null +++ b/crates/perry/tests/issue_9600_bun_cli_utilities.rs @@ -0,0 +1,134 @@ +//! #9600 — Bun's compact CLI utility shim pack, exercised through a compiled +//! standalone binary. Expected values are from Bun 1.3.14. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn bun_cli_utility_oracle_and_edge_cases() { + let stdout = compile_and_run( + r#" +import { + YAML, TOML, semver, deepEquals, stripANSI, wrapAnsi, which, + zstdDecompress, zstdDecompressSync, hash, JSONL, gc, + generateHeapSnapshot, +} from "bun"; + +async function main() { + console.log(JSON.stringify(YAML.parse("a: 1\nb:\n - x"))); + console.log(JSON.stringify(TOML.parse('a = 1\n[b]\nc = "x"'))); + console.log(semver.order("1.0.0", "2.0.0"), semver.satisfies("1.2.3", "^1.0.0")); + try { semver.order("bad", "1.0.0"); } catch (error: any) { + console.log(error.name, error.message.trim()); + } + const semverCases = [ + ["1.2.3", "1.2.3"], ["v1.2.3", "^1.0.0"], + ["1.2.5", "~1.2.3"], ["1.3.0", "~1.2.3"], + ["0.2.5", "^0.2.3"], ["0.3.0", "^0.2.3"], + ["1.5.0", "1.2.3 - 2.0.0"], ["2.1.0", "1.2.3 - 2.0.0"], + ["1.4.9", "1.4.x"], ["1.5.0", "1.4.x"], + ["2.0.0", "<1 || >=2"], ["1.5.0", "<1 || >=2"], + ["1.2.3-beta.1", "^1.2.3"], + ["1.2.3-beta.2", ">=1.2.3-beta.1 <1.2.3"], + ["1.2.3", ""], ["1.2.3", "latest"], + ]; + console.log(JSON.stringify(semverCases.map(([version, range]) => semver.satisfies(version, range)))); + console.log(deepEquals({ a: [1, 2] }, { a: [1, 2] })); + console.log(JSON.stringify(stripANSI("\x1b[31ma\x1b[0mb"))); + console.log(JSON.stringify(wrapAnsi("one two three", 7))); + console.log(which("definitely-not-a-real-command-9600")); + console.log(hash.xxHash64("abc").toString()); + + const compressed = new Uint8Array([40,181,47,253,4,88,81,0,0,104,101,108,108,111,32,122,115,116,100,207,219,96,156]); + console.log(new TextDecoder().decode(zstdDecompressSync(compressed))); + console.log(new TextDecoder().decode(await zstdDecompress(compressed))); + + const parsed = JSONL.parseChunk('{"a":1}\n{"b":2}'); + console.log(JSON.stringify({ values: parsed.values, read: parsed.read, done: parsed.done, error: parsed.error })); + const incomplete = JSONL.parseChunk('{"a":1}\n{"b":'); + console.log(JSON.stringify({ values: incomplete.values, read: incomplete.read, done: incomplete.done, error: incomplete.error })); + const invalid = JSONL.parseChunk('{"a":1}\nnope\n'); + console.log(JSON.stringify({ values: invalid.values, read: invalid.read, done: invalid.done, error: invalid.error?.name })); + + const alias = YAML.parse("a: &shared\n x: 1\nb: *shared"); + console.log(alias.a === alias.b, JSON.stringify(alias)); + const cycle = YAML.parse("root: &root\n self: *root"); + console.log(cycle.root === cycle.root.self); + console.log(JSON.stringify(YAML.parse("---\na: 1\n---\nb: 2"))); + const roundTrip = YAML.parse(YAML.stringify({ a: 1, b: ["x"] }, null, 2)); + console.log(JSON.stringify(roundTrip)); + try { YAML.parse("a: ["); } catch (error: any) { console.log(error.name); } + + console.log(gc() === undefined); + try { generateHeapSnapshot(); } catch (error: any) { + console.log(error.name, error.message.includes("v8")); + } +} +main(); +"#, + ); + assert_eq!( + stdout, + concat!( + "{\"a\":1,\"b\":[\"x\"]}\n", + "{\"a\":1,\"b\":{\"c\":\"x\"}}\n", + "-1 true\n", + "Error Invalid SemVer: bad\n", + "[true,true,true,false,true,false,true,false,true,false,true,false,false,true,true,true]\n", + "true\n", + "\"ab\"\n", + "\"one two\\nthree\"\n", + "null\n", + "4952883123889572249\n", + "hello zstd\n", + "hello zstd\n", + "{\"values\":[{\"a\":1},{\"b\":2}],\"read\":15,\"done\":true,\"error\":null}\n", + "{\"values\":[{\"a\":1}],\"read\":7,\"done\":false,\"error\":null}\n", + "{\"values\":[{\"a\":1}],\"read\":7,\"done\":false,\"error\":\"SyntaxError\"}\n", + "true {\"a\":{\"x\":1},\"b\":{\"x\":1}}\n", + "true\n", + "[{\"a\":1},{\"b\":2}]\n", + "{\"a\":1,\"b\":[\"x\"]}\n", + "SyntaxError\n", + "true\n", + "TypeError true\n", + ) + ); +}