Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions crates/perry-runtime/src/regex/perex_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,45 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result<f
Ok(output.value())
}

/// Is this `limit` free of a coercion that can run user code or throw?
///
/// The plain algorithm raises by throwing where this module returns `Err`, so
/// any input whose coercion can fail is left to the engine path rather than
/// having its exception translated.
fn limit_is_plain(limit_value: &RuntimeHandle<'_>) -> 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<bool, EngineError> {
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<f64, EngineError> {
if matches!(receiver.to_bits(), TAG_NULL | TAG_UNDEFINED) {
return Err(EngineError::Type(
Expand All @@ -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<f64, EngineError> {
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)?;
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-runtime/src/string/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
18 changes: 8 additions & 10 deletions crates/perry-runtime/src/string/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading