diff --git a/crates/perry-runtime/src/regex/perex_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index 7dfd71645e..053897f191 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -319,6 +319,45 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result) -> bool { + let bits = limit_value.get_nanbox_f64().to_bits(); + bits == TAG_UNDEFINED || crate::value::JSValue::from_bits(bits).is_number() +} + +/// Is this separator already a string with no lone surrogate in it? +/// +/// The plain algorithm scans WTF-8 bytes where the engine reads UTF-16 units, +/// so it cannot match a separator that is one half of a valid pair -- +/// `"\u{1F600}\u{1F600}".split(lowHalf)` is three parts to the engine and one +/// to a byte scan. WTF-8 spells a surrogate `ED A0..BF xx`, so this test is +/// exact rather than conservative. A separator that is not already a string is +/// excluded too: its `ToString` can run user code, and a Symbol must throw. +fn separator_is_plain( + scope: &RuntimeHandleScope, + separator: &RuntimeHandle<'_>, +) -> Result { + let jv = crate::value::JSValue::from_bits(separator.get_nanbox_f64().to_bits()); + if !jv.is_string() && !jv.is_short_string() { + return Ok(false); + } + // Already a string, so this coercion runs no user code; it only puts the + // value in the one representation whose bytes can be read. + let sep = text(scope, separator)?; + // SAFETY: a rooted string handle; the borrow spans no allocation or call. + Ok(unsafe { + sep.with_string_bytes(|bytes| { + !bytes + .windows(2) + .any(|w| w[0] == 0xED && (0xA0..=0xBF).contains(&w[1])) + }) + }) +} + pub(crate) fn string(receiver: f64, separator: f64, limit_value: f64) -> Result { if matches!(receiver.to_bits(), TAG_NULL | TAG_UNDEFINED) { return Err(EngineError::Type( @@ -343,6 +382,56 @@ pub(crate) fn string(receiver: f64, separator: f64, limit_value: f64) -> Result< return call(&method, &separator, &args, &memory); } } + // No `@@split`, so the plain string algorithm applies and the engine has + // nothing to contribute. Hand it to the implementation a build without the + // engine uses. + // + // Linking `regex-engine` replaces `String.prototype.split` with this module + // wholesale, so a program using a regex *anywhere* ran every split through + // the engine's per-unit subject reader: 35,826 instructions for + // `"alpha beta gamma delta eps0".split(" ")` against 3,662 without the + // engine, and 2,729 in Node 26.5.1. Both arms auto-optimized, so that is the + // implementation swap rather than the build mode. + // + // The plain algorithm agrees with Node on 27 cases where a byte scan and a + // UTF-16 unit scan can disagree -- empty separator, separator longer than + // the subject, every `limit` form, lone surrogates, an astral pair split by + // units, a separator that is a prefix of itself at the tail -- and on every + // non-string separator form. The one thing it does not implement is + // `@@split`, which is why this sits below that check. + // The two implementations report failure differently: this one returns + // `Err(EngineError)` for `api::finish` to raise at the ABI boundary, while + // the plain algorithm throws directly (its own boundary is the ABI). A + // coercion that throws -- `ToNumber` on a BigInt `limit`, say -- would + // otherwise escape as an uncaught exception, so the throw is captured here + // and re-raised by `finish` like any other engine error. + if limit_is_plain(&limit_value) && separator_is_plain(&scope, &separator)? { + // The plain algorithm reports failure by throwing, where this one + // returns `Err` for `api::finish` to raise; `delegable` has already + // excluded every input whose coercion can throw, so nothing escapes. + return api::caught(|| { + crate::string::js_string_split_plain( + receiver.get_nanbox_f64(), + separator.get_nanbox_f64(), + limit_value.get_nanbox_f64(), + ) + }); + } + string_via_engine( + receiver.get_nanbox_f64(), + separator.get_nanbox_f64(), + limit_value.get_nanbox_f64(), + ) +} + +/// The engine's split, for inputs `delegable` excludes. +fn string_via_engine(receiver: f64, separator: f64, limit_value: f64) -> Result { + let scope = RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let separator = scope.root_nanbox_f64(separator); + let limit_value = scope.root_nanbox_f64(limit_value); + let mut budget = Budget::new(api::WORK); + let memory = MemoryBudget::new(api::SCRATCH_BYTES); let input = text(&scope, &receiver)?; let lim = limit(&limit_value)?; let needle = text(&scope, &separator)?; diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 858dde3518..6762d72506 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -247,6 +247,10 @@ pub use slice_ops::{ js_string_trim_start, }; pub use split::js_string_split; +/// The engine-free `String.prototype.split`, for the engine path to delegate to +/// once it has ruled out `@@split`. +#[cfg(feature = "regex-engine")] +pub(crate) use split::js_string_split_js as js_string_split_plain; #[cfg(not(feature = "regex-engine"))] pub use split::{js_string_split_js, js_string_split_n}; diff --git a/crates/perry-runtime/src/string/split.rs b/crates/perry-runtime/src/string/split.rs index 4bce6e71e5..389aa16c3a 100644 --- a/crates/perry-runtime/src/string/split.rs +++ b/crates/perry-runtime/src/string/split.rs @@ -9,7 +9,6 @@ use crate::array::ArrayHeader; /// a per-element layout-map update. The write barrier remains necessary if a /// collection has promoted the rooted result array while it is being built. #[inline] -#[cfg(not(feature = "regex-engine"))] unsafe fn store_split_string(arr: *mut ArrayHeader, index: usize, string: *mut StringHeader) { const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -298,8 +297,12 @@ pub extern "C" fn js_string_to_upper_case_split_part_utf16_length( /// `limit < 0` → no limit (matches `js_string_split`). /// `limit == 0` → empty array. /// `limit > 0` → at most `limit` substrings. -#[cfg(not(feature = "regex-engine"))] -#[no_mangle] +// Compiled in both builds. Linking `regex-engine` re-exports the engine's +// `split` from `crate::string` instead of this one (see `string::mod`), but the +// engine path delegates back here once it has ruled out `@@split`, so the +// implementation must exist either way. Only the exported C symbols are +// conditional -- they would collide with the engine's. +#[cfg_attr(not(feature = "regex-engine"), no_mangle)] pub extern "C" fn js_string_split_n( s: *const StringHeader, delimiter: *const StringHeader, @@ -539,7 +542,6 @@ pub extern "C" fn js_string_split_n( /// `ToUint32(ToNumber(value))` (ECMA-262 §7.1.7). Runs the full `ToNumber` /// (so a boxed `{ valueOf }` / `{ toString }` argument is coerced and may /// throw), then reduces mod 2^32. `NaN`/`±Infinity`/`0` → 0. -#[cfg(not(feature = "regex-engine"))] fn split_limit_to_uint32(boxed: f64) -> u32 { let n = crate::builtins::js_number_coerce(boxed); if !n.is_finite() || n == 0.0 { @@ -550,7 +552,6 @@ fn split_limit_to_uint32(boxed: f64) -> u32 { /// Build the single-element array `[S]` (the `separator === undefined` result /// of `String.prototype.split`). -#[cfg(not(feature = "regex-engine"))] fn split_single_element(s: *const StringHeader) -> *mut ArrayHeader { const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -579,15 +580,13 @@ fn split_single_element(s: *const StringHeader) -> *mut ArrayHeader { /// - `limit === 0` ⇒ empty array; /// - `separator === undefined` ⇒ single-element `[S]`; /// - otherwise split by `ToString(separator)`, capped at `lim`. -#[cfg(not(feature = "regex-engine"))] -#[no_mangle] +#[cfg_attr(not(feature = "regex-engine"), no_mangle)] pub extern "C" fn js_string_split_value( s: *const StringHeader, separator: f64, limit: f64, ) -> *mut ArrayHeader { use crate::value::JSValue; - #[cfg(feature = "regex-engine")] let sep_jv = JSValue::from_bits(separator.to_bits()); let lim_jv = JSValue::from_bits(limit.to_bits()); let scope = crate::gc::RuntimeHandleScope::new(); @@ -661,8 +660,7 @@ pub extern "C" fn js_string_split_value( js_string_split_n(s, r_str, limit_i32) } -#[cfg(not(feature = "regex-engine"))] -#[no_mangle] +#[cfg_attr(not(feature = "regex-engine"), no_mangle)] pub extern "C" fn js_string_split_js(receiver: f64, separator: f64, limit: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(receiver);