diff --git a/changelog.d/10225-regex-replace-direct.md b/changelog.d/10225-regex-replace-direct.md new file mode 100644 index 0000000000..ae407c4dea --- /dev/null +++ b/changelog.d/10225-regex-replace-direct.md @@ -0,0 +1,3 @@ +### Performance + +- **RegExp `replace` no longer builds an exec result object per match** (#10225). When a regular expression's `exec` is the builtin and it has no named groups, `str.replace(re, …)` collects each match's positions directly and builds the output from pieces of the original string, instead of creating a result array per match and reading it back property by property. A callback replacement over 400,000 matches uses 2.7× less CPU, a `"[$&]"` template 3.0× less, and a `"$2$1"` template 4.5× less; results, callback arguments and `lastIndex` are unchanged, and every match is still found before the first callback runs. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index dea88d140a..cfa84c979c 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -41,6 +41,8 @@ mod perex_public; #[cfg(feature = "regex-engine")] mod perex_replace; #[cfg(feature = "regex-engine")] +mod perex_replace_direct; +#[cfg(feature = "regex-engine")] mod perex_reuse; #[cfg(feature = "regex-engine")] mod perex_split; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs index b17ba58705..ae48894c50 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs @@ -4,7 +4,7 @@ use crate::regex::{perex_api as api, perex_dispatch as dispatch, perex_replace a use crate::value::{ js_nanbox_get_pointer, js_nanbox_pointer, js_nanbox_string, TAG_NULL, TAG_UNDEFINED, }; -fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { +pub(super) fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { scope.root_nanbox_f64(js_nanbox_string(crate::string::js_string_from_bytes( bytes.as_ptr(), bytes.len() as u32, @@ -15,7 +15,11 @@ fn object<'s>(scope: &'s RuntimeHandleScope) -> RuntimeHandle<'s> { crate::object::js_object_alloc(0, 8) as i64 )) } -fn regex<'s>(scope: &'s RuntimeHandleScope, source: &[u8], flags: &[u8]) -> RuntimeHandle<'s> { +pub(super) fn regex<'s>( + scope: &'s RuntimeHandleScope, + source: &[u8], + flags: &[u8], +) -> RuntimeHandle<'s> { let source = text(scope, source); let flags = text(scope, flags); scope.root_nanbox_f64(js_nanbox_pointer(crate::regex::js_regexp_construct( @@ -23,16 +27,20 @@ fn regex<'s>(scope: &'s RuntimeHandleScope, source: &[u8], flags: &[u8]) -> Runt flags.get_nanbox_f64(), ) as i64)) } -fn function<'s>(scope: &'s RuntimeHandleScope, fp: *const u8, arity: u32) -> RuntimeHandle<'s> { +pub(super) fn function<'s>( + scope: &'s RuntimeHandleScope, + fp: *const u8, + arity: u32, +) -> RuntimeHandle<'s> { crate::closure::js_register_closure_arity(fp, arity); scope.root_nanbox_f64(js_nanbox_pointer( crate::closure::js_closure_alloc_singleton(fp) as i64, )) } -fn get(owner: &RuntimeHandle<'_>, name: &[u8]) -> f64 { +pub(super) fn get(owner: &RuntimeHandle<'_>, name: &[u8]) -> f64 { api::finish(dispatch::get(owner, name)) } -fn put(owner: &RuntimeHandle<'_>, name: &[u8], value: f64) { +pub(super) fn put(owner: &RuntimeHandle<'_>, name: &[u8], value: f64) { let scope = RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(value); let key = crate::string::canonical_key(name); @@ -76,12 +84,12 @@ fn accessor(owner: &RuntimeHandle<'_>, key: f64, getter: f64, setter: f64) { ); } -fn bytes(value: f64) -> Vec { +pub(super) fn bytes(value: f64) -> Vec { let mut short = [0; crate::value::SHORT_STRING_MAX_LEN]; let (data, n) = crate::string::str_bytes_from_jsvalue(value, &mut short).unwrap(); unsafe { std::slice::from_raw_parts(data, n as usize).to_vec() } } -fn captured<'s>( +pub(super) fn captured<'s>( scope: &'s RuntimeHandleScope, fp: *const u8, arity: u32, diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs new file mode 100644 index 0000000000..8e83c9a2f1 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs @@ -0,0 +1,237 @@ +//! RegExp `@@replace` without exec result objects (#10165): the same output +//! and final `lastIndex` as the ordinary loop, and only for receivers where +//! skipping the objects cannot be seen. +use super::perex_replace::{bytes, captured, function, get, put, regex, text}; +use super::*; +use crate::regex::perex_api as api; +use crate::regex::perex_replace_direct::{direct_replaces, DisableDirectReplaceForTest}; +use crate::value::{js_nanbox_string, TAG_UNDEFINED}; + +/// Replace through the public ABI; the output bytes and the receiver's final +/// `lastIndex`, and whether the direct path served it. +fn replace_all( + source: &[u8], + flags: &[u8], + input: &[u8], + replacement: impl Fn(&RuntimeHandleScope, &RuntimeHandle<'_>) -> f64, +) -> (Vec, f64, bool) { + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, source, flags); + let input = text(&scope, input); + let replacement = scope.root_nanbox_f64(replacement(&scope, &re)); + let before = direct_replaces(); + let result = scope.root_nanbox_f64(crate::regex::js_string_replace_js( + input.get_nanbox_f64(), + re.get_nanbox_f64(), + replacement.get_nanbox_f64(), + )); + ( + bytes(result.get_nanbox_f64()), + get(&re, b"lastIndex"), + direct_replaces() > before, + ) +} + +fn both( + source: &[u8], + flags: &[u8], + input: &[u8], + replacement: impl Fn(&RuntimeHandleScope, &RuntimeHandle<'_>) -> f64 + Copy, +) -> (Vec, f64) { + let (direct, direct_last, served) = replace_all(source, flags, input, replacement); + assert!( + served, + "{:?} /{:?}/ must take the direct path", + std::str::from_utf8(source), + flags + ); + let (ordinary, ordinary_last, served) = { + let _off = DisableDirectReplaceForTest::new(); + replace_all(source, flags, input, replacement) + }; + assert!(!served); + assert_eq!( + (String::from_utf8_lossy(&direct), direct_last), + (String::from_utf8_lossy(&ordinary), ordinary_last), + "/{}/{} over {:?}", + String::from_utf8_lossy(source), + String::from_utf8_lossy(flags), + String::from_utf8_lossy(input) + ); + (direct, direct_last) +} + +fn template( + value: &'static [u8], +) -> impl Fn(&RuntimeHandleScope, &RuntimeHandle<'_>) -> f64 + Copy { + move |scope, _| text(scope, value).get_nanbox_f64() +} + +/// Pattern, flags, input, template, and the expected output when it is pinned. +type Case = ( + &'static [u8], + &'static [u8], + &'static [u8], + &'static [u8], + &'static str, +); + +#[test] +fn direct_templates_match_the_ordinary_loop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let cases: &[Case] = &[ + ( + b"(a)(z)?", + b"g", + b"abaz a", + b"[$&|$1|$2|$`|$'|$$|$0|$3|$10|$|$]", + "", + ), + (b"(\\d)(\\d)?", b"g", b"1 23 4", b"<$2$1$11$01$12$00>", ""), + (b"a", b"", b"banana", b"[$`|$']", "b[b|nana]nana"), + (b"", b"g", "aé😀b".as_bytes(), b"<$&>", ""), + (b"", b"gu", "aé😀b".as_bytes(), b"<$&>", "<>a<>é<>😀<>b<>"), + (b"b", b"y", b"abba", b"X", "abba"), + (b"b", b"gy", b"bba", b"X", "XXa"), + ( + "[äö]+".as_bytes(), + b"g", + "xäöyö".as_bytes(), + b"($&)", + "x(äö)y(ö)", + ), + (b"x", b"g", b"no match here", b"Y", "no match here"), + ]; + for (source, flags, input, replacement, expected) in cases { + let (output, _) = both(source, flags, input, template(replacement)); + if !expected.is_empty() { + assert_eq!(String::from_utf8_lossy(&output), *expected); + } + } +} + +extern "C" fn describe( + _: *const crate::closure::ClosureHeader, + matched: f64, + capture: f64, + position: f64, + input: f64, +) -> f64 { + let capture = if capture.to_bits() == TAG_UNDEFINED { + "undefined".to_string() + } else { + String::from_utf8_lossy(&bytes(capture)).into_owned() + }; + let text = format!( + "{{{}:{}@{}/{}}}", + String::from_utf8_lossy(&bytes(matched)), + capture, + position, + bytes(input).len() + ); + gc_collect_minor(); + js_nanbox_string(crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32) as i64) +} + +#[test] +fn direct_callbacks_receive_the_ordinary_arguments() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let callback = |scope: &RuntimeHandleScope, _: &RuntimeHandle<'_>| { + function(scope, describe as *const u8, 4).get_nanbox_f64() + }; + let (output, _) = both(b"(b)?a", b"g", "bä a ba".as_bytes(), callback); + assert_eq!( + String::from_utf8_lossy(&output), + "bä {a:undefined@3/8} {ba:b@5/8}" + ); +} + +extern "C" fn meddle( + c: *const crate::closure::ClosureHeader, + matched: f64, + _position: f64, + _input: f64, +) -> f64 { + let scope = RuntimeHandleScope::new(); + let matched = scope.root_nanbox_f64(matched); + let state = scope.root_nanbox_f64(crate::closure::js_closure_get_capture_f64(c, 0)); + // Rewind the receiver and give it an own exec that matches nothing. Every + // match was collected before the first call, so neither can change them. + api::finish(crate::regex::perex_dispatch::set_last_index(&state, 0.0)); + let never = function(&scope, never_exec as *const u8, 1); + put(&state, b"exec", never.get_nanbox_f64()); + matched.get_nanbox_f64() +} + +extern "C" fn never_exec(_: *const crate::closure::ClosureHeader, _: f64) -> f64 { + f64::from_bits(crate::value::TAG_NULL) +} + +#[test] +fn a_replacer_cannot_change_which_matches_are_replaced() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let callback = |scope: &RuntimeHandleScope, re: &RuntimeHandle<'_>| { + captured(scope, meddle as *const u8, 3, re).get_nanbox_f64() + }; + let (output, last) = both(b"o", b"g", b"foo boo", callback); + assert_eq!(output, b"foo boo"); + assert_eq!(last, 0.0); +} + +extern "C" fn counting_exec(c: *const crate::closure::ClosureHeader, _: f64) -> f64 { + let scope = RuntimeHandleScope::new(); + let state = scope.root_nanbox_f64(crate::closure::js_closure_get_capture_f64(c, 0)); + put(&state, b"calls", get(&state, b"calls") + 1.0); + f64::from_bits(crate::value::TAG_NULL) +} + +#[test] +fn an_own_exec_or_named_groups_keep_the_ordinary_loop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + + let (_, _, served) = replace_all(b"a", b"g", b"banana", |scope, re| { + put(re, b"calls", 0.0); + let exec = captured(scope, counting_exec as *const u8, 1, re); + put(re, b"exec", exec.get_nanbox_f64()); + text(scope, b"X").get_nanbox_f64() + }); + assert!( + !served, + "an own exec must be called, so the direct path must decline" + ); + + let (output, _, served) = replace_all(b"(?a)", b"g", b"banana", template(b"[$]")); + assert!( + !served, + "named groups need the groups object, so the direct path must decline" + ); + assert_eq!(output, b"b[a]n[a]n[a]"); +} + +#[test] +fn direct_match_spans_are_not_capped_by_the_scratch_limit() { + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + // 63 groups: each match stores 64 span pairs, 128 entries. One match more + // than a SCRATCH_BYTES / 8 entry cap (the limit #10207 removed from the + // piece lists) allows, so a cap on span storage would throw here. + let groups = 63; + let entries_per_match = (groups + 1) * 2; + let matches = api::SCRATCH_BYTES / 8 / entries_per_match + 1; + let source = "(a)".repeat(groups) + "a"; + let input = "a".repeat((groups + 1) * matches); + let (output, last, served) = + replace_all(source.as_bytes(), b"g", input.as_bytes(), template(b"$1")); + assert!(served, "the witness must run on the direct path"); + assert_eq!(output.len(), matches); + assert!(output.iter().all(|&byte| byte == b'a')); + assert_eq!(last, 0.0); +} diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 8594911285..740cc5ea15 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -50,6 +50,8 @@ pub(crate) mod perex_position_hint; #[cfg(feature = "regex-engine")] pub(crate) mod perex_replace; #[cfg(feature = "regex-engine")] +pub(crate) mod perex_replace_direct; +#[cfg(feature = "regex-engine")] mod perex_replace_storage; #[cfg(feature = "regex-engine")] mod perex_substitution; diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index f003a4e640..7d7e4c5f74 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -271,6 +271,17 @@ impl<'b, 's> Reuse<'b, 's> { /// Both roots are live, so equal addresses name the same objects even after /// either moved; a replaced program cannot reuse a cell this root retains. + /// How many named groups the program of the RegExp at `receiver` declares, + /// when this binding is for that receiver's current program. + pub(crate) fn name_count(&self, receiver: *const RegExpHeader) -> Option { + let reused = self.program.as_ref()?; + let bound = reused.receiver.with_const_ptr::(|p| p); + let cell = reused.cell.with_const_ptr::(|p| p); + (receiver == bound && unsafe { (*receiver).perex_program } == cell) + .then(|| reused.bound.with_view(|program| program.name_count()).ok()) + .flatten() + } + fn program_for(&self, receiver: &RuntimeHandle<'_>) -> Option<&BoundProgram>> { let reused = self.program.as_ref()?; let current = receiver.with_const_ptr::(|p| p); @@ -384,6 +395,36 @@ pub(crate) fn execute_with_resources( memory: &MemoryBudget, poll: &mut impl FnMut() -> Result<(), EngineError>, reuse: Option<&Reuse<'_, '_>>, +) -> Result, EngineError> { + let output = if materialize { + ExecOutput::Object + } else { + ExecOutput::Test + }; + execute_output(receiver, input, output, budget, memory, poll, reuse) +} + +/// What a builtin search produces when it matches. +pub(crate) enum ExecOutput<'v> { + /// Only the full match (`test`). + Test, + /// The exec result array and its groups object. + Object, + /// Every capture span, group zero first, appended to the vector as + /// UTF-16 `start, end` pairs, with `u32::MAX, u32::MAX` for an unset + /// group. No JS object is created (#10165). + Spans(&'v mut Vec), +} + +/// [`execute_with_resources`] with an explicit output. +pub(crate) fn execute_output( + receiver: *mut RegExpHeader, + input: *const StringHeader, + output: ExecOutput<'_>, + budget: &mut Budget, + memory: &MemoryBudget, + poll: &mut impl FnMut() -> Result<(), EngineError>, + reuse: Option<&Reuse<'_, '_>>, ) -> Result, EngineError> { let scope = RuntimeHandleScope::new(); let receiver = scope.root_raw_mut_ptr(receiver); @@ -434,10 +475,10 @@ pub(crate) fn execute_with_resources( subject, start, near, - if materialize { - CaptureMode::All - } else { + if matches!(output, ExecOutput::Test) { CaptureMode::Full + } else { + CaptureMode::All }, budget, memory, @@ -462,10 +503,10 @@ pub(crate) fn execute_with_resources( let Some(found) = found else { return Ok(None); }; - let (array, groups) = if materialize { + let (array, groups) = match output { // Captures and all native owners remain ABOVE the JS trap. A thrown // allocation/property operation returns here before they are dropped. - caught(|| { + ExecOutput::Object => caught(|| { super::perex_results::materialize( &input, subject, @@ -477,9 +518,26 @@ pub(crate) fn execute_with_resources( budget, poll, ) - })?? - } else { - (std::ptr::null_mut(), std::ptr::null_mut()) + })??, + ExecOutput::Spans(spans) => { + let captures = found.captures.as_ref().ok_or(EngineError::InvalidSpan)?; + spans + .try_reserve(captures.len() * 2) + .map_err(|_| StorageError::Allocation)?; + for capture in captures.iter() { + let (start, end) = match capture { + Some(span) => ( + u32::try_from(span.start()).map_err(|_| StorageError::Limit)?, + u32::try_from(span.end()).map_err(|_| StorageError::Limit)?, + ), + None => (u32::MAX, u32::MAX), + }; + spans.push(start); + spans.push(end); + } + (std::ptr::null_mut(), std::ptr::null_mut()) + } + ExecOutput::Test => (std::ptr::null_mut(), std::ptr::null_mut()), }; Ok(Some(ExecMatch { full: found.full, diff --git a/crates/perry-runtime/src/regex/perex_replace.rs b/crates/perry-runtime/src/regex/perex_replace.rs index 303c6fa1c1..91dbed8d25 100644 --- a/crates/perry-runtime/src/regex/perex_replace.rs +++ b/crates/perry-runtime/src/regex/perex_replace.rs @@ -75,6 +75,21 @@ pub(crate) fn regexp(receiver: f64, argument: f64, replacement: f64) -> Result = const { std::cell::Cell::new(0) }; + static DIRECT_DISABLED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn direct_replaces() -> usize { + DIRECT_REPLACES.with(std::cell::Cell::get) +} + +/// The ordinary loop runs while this is held, so a test can compare the two. +#[cfg(test)] +pub(crate) struct DisableDirectReplaceForTest(bool); + +#[cfg(test)] +impl DisableDirectReplaceForTest { + pub(crate) fn new() -> Self { + Self(DIRECT_DISABLED.with(|d| d.replace(true))) + } +} + +#[cfg(test)] +impl Drop for DisableDirectReplaceForTest { + fn drop(&mut self) { + DIRECT_DISABLED.with(|d| d.set(self.0)); + } +} + +/// Whether this replacement may skip exec result objects. Non-observable. +pub(super) fn admissible(receiver: &RuntimeHandle<'_>, reuse: &Reuse<'_, '_>) -> bool { + #[cfg(test)] + if DIRECT_DISABLED.with(std::cell::Cell::get) { + return false; + } + let value = receiver.get_nanbox_f64(); + let re = crate::value::js_nanbox_get_pointer(value) as *const RegExpHeader; + super::is_valid_regex_ptr(re) + && crate::object::regex_proto_thunks::regexp_view_uses_builtin(value) + && reuse.name_count(re) == Some(0) +} + +/// One piece of a template: a span of the template itself, or a part of the +/// current match. Parsed once; the capture count is the program's. +#[derive(Clone, Copy)] +enum Token { + Template(usize, usize), + Matched, + Before, + After, + Capture(usize), +} + +/// GetSubstitution's scan (as `perex_substitution` performs it) with no named +/// groups, recorded once instead of per match. +fn parse( + template: &RuntimeHandle<'_>, + captures: usize, + budget: &mut Budget, +) -> Result, EngineError> { + let bound = super::perex_match_search::subject(*template)?; + let mut reader = Units::new(&bound)?; + let n = length(template); + let mut tokens = Vec::new(); + let (mut i, mut literal) = (0, 0); + while i < n { + if reader.at(i, budget)? != b'$' as u16 || i + 1 == n { + i += 1; + continue; + } + let marker = reader.at(i + 1, budget)?; + let mut next = i + 2; + let token = match marker { + 0x24 => Token::Template(i, i + 1), + 0x26 => Token::Matched, + 0x60 => Token::Before, + 0x27 => Token::After, + 0x30..=0x39 => { + let mut index = (marker - 0x30) as usize; + if next < n { + let second = reader.at(next, budget)?; + if (0x30..=0x39).contains(&second) { + let two = index * 10 + (second - 0x30) as usize; + if two > 0 && two <= captures { + index = two; + next += 1; + } + } + } + if index == 0 || index > captures { + i += 1; + continue; + } + Token::Capture(index) + } + _ => { + i += 1; + continue; + } + }; + tokens + .try_reserve(2) + .map_err(|_| StorageError::Allocation)?; + tokens.push(Token::Template(literal, i)); + tokens.push(token); + i = next; + literal = i; + } + tokens.push(Token::Template(literal, n)); + Ok(tokens) +} + +/// Every match's capture spans for one replacement. Their size follows the +/// subject (matches times captures), not a fixed operation limit, so they are +/// not charged to the `MemoryBudget`: a cap there made large replacements +/// throw where Node completes (#10164). The collector is told about them as +/// external bytes, like other runtime side storage. +struct Spans { + values: Vec, + noted: usize, +} + +impl Spans { + fn note_growth(&mut self) -> Result<(), EngineError> { + let bytes = self.values.capacity() * std::mem::size_of::(); + if bytes > self.noted { + let grown = bytes - self.noted; + self.noted = bytes; + api::caught(|| crate::gc::gc_note_external_side_alloc(grown))?; + } + Ok(()) + } +} + +impl Drop for Spans { + fn drop(&mut self) { + crate::gc::gc_note_external_side_free(self.noted); + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn replace( + scope: &RuntimeHandleScope, + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + bound: &BoundSubject>, + reuse: &Reuse<'_, '_>, + global: bool, + unicode: bool, + replacement: &RuntimeHandle<'_>, + template: Option<&RuntimeHandle<'_>>, + budget: &mut Budget, + memory: &MemoryBudget, +) -> Result { + #[cfg(test)] + DIRECT_REPLACES.with(|n| n.set(n.get() + 1)); + // The ordinary loop's RegExpExec adds a reference to the input per search. + input.with_mut_ptr::(|input| crate::string::js_string_addref(input)); + let input_length = length(input); + let mut spans = Spans { + values: Vec::new(), + noted: 0, + }; + let mut width = 0; + let mut searches = 0usize; + loop { + host::charge(budget, 1)?; + // Each match ends at or after the next search's start, and an empty one + // advances `lastIndex`, so a global loop runs at most once per position + // plus the final failing search. More means it stopped advancing. + searches += 1; + debug_assert!( + searches <= input_length + 2, + "a global replace searched more often than its input has positions" + ); + let before = spans.values.len(); + // Re-read the receiver every search: the previous one may have collected. + let re = + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader; + let found = input.with_const_ptr::(|input| { + api::execute_output( + re, + input, + ExecOutput::Spans(&mut spans.values), + budget, + memory, + &mut host::poll, + Some(reuse), + ) + })?; + spans.note_growth()?; + let Some(found) = found else { + break; + }; + width = spans.values.len() - before; + if !global { + break; + } + if found.full.is_empty() { + let local = RuntimeHandleScope::new(); + let index = local.root_nanbox_f64(super::perex_dispatch::get(receiver, b"lastIndex")?); + let index = super::perex_dispatch::to_length(&index)?; + let next = advance(bound, index, input_length, unicode, budget)?; + super::perex_dispatch::set_last_index(receiver, next)?; + } + host::poll()?; + } + if spans.values.is_empty() { + return Ok(boxed(input)); + } + let captures = (width / 2).saturating_sub(1); + let tokens = template.map(|t| parse(t, captures, budget)).transpose()?; + let mut copies = SpanCopies::new(bound)?; + let mut output = Pieces::new(scope)?; + let mut next_source = 0; + for record in spans.values.chunks_exact(width) { + let local = RuntimeHandleScope::new(); + let (start, end) = (record[0] as usize, record[1] as usize); + let position = start.min(input_length); + let accepted = position >= next_source; + if accepted { + output.append(input, next_source, position, budget)?; + } + if let Some(tokens) = tokens.as_ref() { + if accepted { + for token in tokens { + match *token { + Token::Template(a, b) => output.append(template.unwrap(), a, b, budget)?, + Token::Matched => output.append(input, start, end, budget)?, + Token::Before => output.append(input, 0, position, budget)?, + Token::After => { + output.append(input, end.min(input_length), input_length, budget)? + } + Token::Capture(index) => { + let (a, b) = (record[2 * index], record[2 * index + 1]); + if a != u32::MAX { + output.append(input, a as usize, b as usize, budget)?; + } + } + } + } + } + } else { + let mut args = List::new(&local)?; + let matched = copies.copy(start, end, budget)?; + args.push(js_nanbox_string(matched as i64), budget)?; + for pair in record[2..].as_chunks::<2>().0 { + if pair[0] == u32::MAX { + args.push(f64::from_bits(TAG_UNDEFINED), budget)?; + } else { + let capture = copies.copy(pair[0] as usize, pair[1] as usize, budget)?; + args.push(js_nanbox_string(capture as i64), budget)?; + } + } + args.push(position as f64, budget)?; + args.push(boxed(input), budget)?; + let this = local.root_nanbox_f64(f64::from_bits(TAG_UNDEFINED)); + let value = local.root_nanbox_f64(call(replacement, &this, &args, memory)?); + let value = text(&local, &value)?; + if accepted { + output.whole(&value, budget)?; + } + } + if accepted { + next_source = end; + } + host::poll()?; + } + if next_source < input_length { + output.append(input, next_source, input_length, budget)?; + } + output + .finish(input, template, budget) + .map(|s| js_nanbox_string(s as i64)) +}