diff --git a/Cargo.lock b/Cargo.lock index 56cb11a139..c1f2dc8119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6322,6 +6322,7 @@ dependencies = [ "perry-parser", "rand 0.10.1", "regex", + "regress", "resolv-conf", "ryu", "serde", @@ -7525,6 +7526,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "rend" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 2623e8a948..0826ae1c39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -372,6 +372,7 @@ libc = "0.2" lazy_static = "1.5" chrono = "0.4" regex = "1.12" +regress = "0.11.1" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/changelog.d/8660-regexp-repeat-matcher.md b/changelog.d/8660-regexp-repeat-matcher.md new file mode 100644 index 0000000000..58ef4221d9 --- /dev/null +++ b/changelog.d/8660-regexp-repeat-matcher.md @@ -0,0 +1 @@ +Fixed RegExp quantified capture groups to follow ECMA-262 RepeatMatcher semantics, including clearing skipped captures and discarding empty optional iterations. diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index dd78b941b5..daf8bad55f 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -89,14 +89,14 @@ mod-node-test = [] # degrade gracefully when off (auto-optimize leaves this off unless the program # uses a heap-snapshot / `process.report` API, which the compiler detects). diagnostics = [] -# The user's regular-expression engine (`regex` + `fancy-regex`, ~1.2 MB of -# DFA/NFA machinery). A program that never evaluates a regex literal, `RegExp`, +# The user's regular-expression engines (`regex`, `fancy-regex`, and `regress`). +# A program that never evaluates a regex literal, `RegExp`, # a regex-coercing string method, or a glob API can't produce a RegExp at # runtime, so the compiler leaves this off and the engine is never linked. The # RegExp object's identity/display layer (header, `is_regex_pointer`, `toString`) # stays always compiled, so console-formatting / value-to-string paths keep # working with no engine present. -regex-engine = ["dep:regex", "dep:fancy-regex"] +regex-engine = ["dep:regex", "dep:fancy-regex", "dep:regress"] # The TC39 `Temporal.*` API (`temporal_rs` + its transitive tz/calendar deps: # jiff-tzdb, icu_calendar, timezone_provider, calendrical_calculations — # ~580 KB). Independent of JS `Date` (which has its own `date.rs` impl), so a @@ -261,6 +261,7 @@ libc.workspace = true gimli = { version = "0.34", default-features = false, features = ["read"] } rand = "0.10" regex = { workspace = true, optional = true } +regress = { workspace = true, optional = true } # Taffy — flexbox / grid layout engine for the perry/tui module # (#358 Phase 3). Same crate Bevy and Dioxus use; pure Rust, no FFI. taffy = { version = "0.13", default-features = false, features = ["std", "flexbox", "taffy_tree"] } diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 67ac66ce07..324fbb3e5e 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -40,6 +40,8 @@ mod grammar; #[cfg(feature = "regex-engine")] mod match_all; #[cfg(feature = "regex-engine")] +mod repeat_matcher; +#[cfg(feature = "regex-engine")] mod replace_expand; mod replace_fn; #[cfg(feature = "regex-engine")] @@ -224,6 +226,7 @@ pub(crate) fn test_alloc_nursery_regexp_for_move(source: &str, flags: &str) -> * (*ptr).last_index = crate::value::JSValue::number(0.0).bits(); (*ptr).magic = REGEXP_MAGIC; (*ptr).fancy_ptr = std::ptr::null(); + (*ptr).repeat_matcher_ptr = std::ptr::null(); REGEX_EVER_REGISTERED.arm(); REGEX_POINTERS.with(|table| { @@ -277,7 +280,7 @@ pub(crate) fn regex_header_has_magic(re: *const RegExpHeader) -> bool { /// * `flags_ptr` — the flags `StringHeader`, /// * `last_index` — a writable JSValue (`re.lastIndex = …`) that may be a /// NaN-boxed heap pointer. -/// `regex_ptr`/`fancy_ptr` point to OFF-heap leaked Rust allocations and the +/// The compiled matcher pointers point to OFF-heap leaked Rust allocations and the /// bool/`magic` fields are never heap refs, so they must NOT be scanned. /// /// `pattern_ptr` and `flags_ptr` are consecutive equal-width fields, so under @@ -301,6 +304,11 @@ crate::perry_thread_local! { static REGEX_CACHE: RefCell>> = RefCell::new(HashMap::new()); /// Fancy-regex fallback cache for patterns with lookbehind/lookahead. static FANCY_CACHE: RefCell>> = RefCell::new(HashMap::new()); + + /// ECMAScript backtracking matchers for quantified capture groups. These + /// are the patterns where `regex`/`fancy-regex` cannot reproduce + /// `RepeatMatcher` capture reset and nullable-iteration semantics (#5897). + static REPEAT_MATCHER_CACHE: RefCell>> = RefCell::new(HashMap::new()); } /// Compiled-program size budget handed to both regex engines. @@ -343,13 +351,13 @@ pub(crate) fn build_fancy_regex(pattern: &str) -> Result bool { if already { return true; } + if let Some(repeat_matcher) = repeat_matcher::compile(pattern, flags) { + REPEAT_MATCHER_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + evict_regex_cache_if_full(&mut cache); + cache.insert( + (pattern.to_string(), flags.to_string()), + Arc::new(repeat_matcher), + ); + }); + } // Translate JS regex to Rust-compatible pattern let translated = js_regex_to_rust(pattern); let case_insensitive = flags.contains('i'); @@ -524,6 +542,10 @@ pub struct RegExpHeader { /// Header-resident twin of the `FANCY_CACHE` thread-local so the fancy /// fallback survives the duplicate-runtime split described above. pub fancy_ptr: *const (), + /// Header-owned `Arc` for quantified capture groups, + /// or null for the ordinary linear/fancy paths. Like `fancy_ptr`, this + /// survives cache eviction and duplicate statically-linked runtime copies. + pub repeat_matcher_ptr: *const (), } /// Self-identifying sentinel stamped into every `RegExpHeader.magic` by @@ -961,6 +983,15 @@ pub extern "C" fn js_regexp_new( None => std::ptr::null(), } }); + (*ptr).repeat_matcher_ptr = REPEAT_MATCHER_CACHE.with(|cache| { + match cache + .borrow() + .get(&(owned_pattern.clone(), flags_str.to_string())) + { + Some(arc) => Arc::into_raw(arc.clone()) as *const (), + None => std::ptr::null(), + } + }); // Record the pointer so that js_string_split can detect // `s.split(regex)` without a dedicated runtime decl. @@ -1131,6 +1162,14 @@ pub extern "C" fn js_regexp_test(re: *const RegExpHeader, s: *const StringHeader return if arr.is_null() { 0 } else { 1 }; } + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + return if repeat_matcher.regex.find(str_data).is_some() { + 1 + } else { + 0 + }; + } + if let Some(fre) = lookup_fancy_regex(re) { return match fre.is_match(str_data) { Ok(true) => 1, @@ -1175,6 +1214,31 @@ pub(crate) fn lookup_fancy_regex(re: *const RegExpHeader) -> Option Option> { + unsafe { + if regex_header_has_magic(re) && !(*re).repeat_matcher_ptr.is_null() { + let raw = (*re).repeat_matcher_ptr as *const repeat_matcher::RepeatMatcherRegex; + let arc = Arc::from_raw(raw); + let cloned = arc.clone(); + std::mem::forget(arc); + return Some(cloned); + } + let pat = string_as_str((*re).pattern_ptr); + let flags_str = string_as_str((*re).flags_ptr); + REPEAT_MATCHER_CACHE.with(|cache| { + cache + .borrow() + .get(&(pat.to_string(), flags_str.to_string())) + .cloned() + }) + } +} + /// Replace matches in a string /// Expand a JS replacement string against one match, supporting the full set @@ -1346,6 +1410,11 @@ pub extern "C" fn js_string_replace_regex( } unsafe { + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + let result = repeat_matcher.replace(str_data, repl_str, (*re).global); + return finish_replace_bytes(result.as_bytes()); + } + // Pattern the `regex` crate couldn't compile (lookbehind/backreferences) // → drive the replacement through fancy-regex. Otherwise the never-match // placeholder in `regex_ptr` would leave the input unchanged. @@ -1450,7 +1519,9 @@ pub extern "C" fn js_string_split_regex_n( unsafe { // Each element is either a substring (`Some`) or `undefined` (`None`, // for an unmatched capture group spliced into the result). - let parts: Vec> = if let Some(fre) = lookup_fancy_regex(re) { + let parts: Vec> = if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + repeat_matcher.split(&str_data, limit) + } else if let Some(fre) = lookup_fancy_regex(re) { // Fancy-regex fallback (lookbehind/backreferences): `fancy_regex` has // no `split`, so walk non-overlapping matches and slice between them. // (Captured-group splicing is not reproduced for this engine.) @@ -1507,6 +1578,14 @@ pub extern "C" fn js_string_search_regex(s: *const StringHeader, re: *const RegE let str_data = string_as_str(s); unsafe { + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + return repeat_matcher + .regex + .find(str_data) + .map(|matched| byte_index_to_utf16_index(str_data, matched.start()) as i32) + .unwrap_or(-1); + } + // Fancy-regex fallback (lookbehind/backreferences): the never-match // placeholder in `regex_ptr` would always report -1 otherwise. if let Some(fre) = lookup_fancy_regex(re) { diff --git a/crates/perry-runtime/src/regex/compile.rs b/crates/perry-runtime/src/regex/compile.rs index 548d2a01e8..a363a0a777 100644 --- a/crates/perry-runtime/src/regex/compile.rs +++ b/crates/perry-runtime/src/regex/compile.rs @@ -161,6 +161,15 @@ pub extern "C" fn js_regexp_compile_value( None => std::ptr::null(), } }); + let repeat_matcher_ptr: *const () = super::REPEAT_MATCHER_CACHE.with(|cache| { + match cache + .borrow() + .get(&(pattern_str.to_string(), flags_str.to_string())) + { + Some(arc) => Arc::into_raw(arc.clone()) as *const (), + None => std::ptr::null(), + } + }); let (canonical_flags_ptr, _) = re_handle.across_mut::(|| js_string_from_str(flags_str)); let canonical_flags_handle = scope.root_string_ptr(canonical_flags_ptr); @@ -171,8 +180,10 @@ pub extern "C" fn js_regexp_compile_value( unsafe { let old_regex_ptr = (*re).regex_ptr; let old_fancy_ptr = (*re).fancy_ptr; + let old_repeat_matcher_ptr = (*re).repeat_matcher_ptr; (*re).regex_ptr = regex_ptr; (*re).fancy_ptr = fancy_ptr; + (*re).repeat_matcher_ptr = repeat_matcher_ptr; // Release the receiver's PREVIOUS owned references now that the new // ones are installed (recompiling the same pattern is fine: the fresh // `into_raw` reference above keeps the shared program alive). @@ -182,6 +193,11 @@ pub extern "C" fn js_regexp_compile_value( if !old_fancy_ptr.is_null() { drop(Arc::from_raw(old_fancy_ptr as *const fancy_regex::Regex)); } + if !old_repeat_matcher_ptr.is_null() { + drop(Arc::from_raw( + old_repeat_matcher_ptr as *const super::repeat_matcher::RepeatMatcherRegex, + )); + } (*re).pattern_ptr = pattern_ptr; (*re).flags_ptr = canonical_flags_ptr; (*re).case_insensitive = flags_str.contains('i'); diff --git a/crates/perry-runtime/src/regex/exec.rs b/crates/perry-runtime/src/regex/exec.rs index fbb6a1f39d..fadfd9dbe7 100644 --- a/crates/perry-runtime/src/regex/exec.rs +++ b/crates/perry-runtime/src/regex/exec.rs @@ -60,7 +60,30 @@ pub extern "C" fn js_regexp_exec( } let search_str = &str_data[search_start_byte..]; - let owned = if let Some(fre) = lookup_fancy_regex(re) { + let owned = if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + repeat_matcher + .regex + .find(search_str) + .filter(|matched| !sticky || matched.start() == 0) + .map(|matched| { + if use_last_index { + set_last_index_throwing( + re, + super::exec_array::byte_index_to_utf16_index( + str_data, + search_start_byte + matched.end(), + ), + ); + } + OwnedExecMatch::from_repeat_matcher( + str_data, + search_start_byte, + &repeat_matcher, + &matched, + has_indices, + ) + }) + } else if let Some(fre) = lookup_fancy_regex(re) { match fre.captures(search_str) { Ok(Some(caps)) if !sticky || caps.get(0).is_some_and(|full| full.start() == 0) => { let full = caps.get(0).expect("capture zero is the full match"); diff --git a/crates/perry-runtime/src/regex/exec_array.rs b/crates/perry-runtime/src/regex/exec_array.rs index 8f5d0252c7..8c9602f6ed 100644 --- a/crates/perry-runtime/src/regex/exec_array.rs +++ b/crates/perry-runtime/src/regex/exec_array.rs @@ -150,6 +150,41 @@ impl OwnedExecMatch { match_index, } } + + pub(super) fn from_repeat_matcher( + str_data: &str, + search_start_byte: usize, + regex: &super::repeat_matcher::RepeatMatcherRegex, + matched: ®ress::Match, + has_indices: bool, + ) -> Self { + let captures: Vec> = matched + .groups() + .map(|capture| { + capture.map(|range| { + OwnedCapture::from_range_with_indices( + str_data, + search_start_byte + range.start, + search_start_byte + range.end, + has_indices, + ) + }) + }) + .collect(); + let named = regex + .capture_names + .iter() + .enumerate() + .filter_map(|(index, name)| name.as_ref().map(|name| (name.clone(), index + 1))) + .collect(); + let match_index = + byte_index_to_utf16_index(str_data, search_start_byte + matched.start()) as f64; + Self { + captures, + named, + match_index, + } + } } /// Match-result metadata helper taking the `input` property as an already-boxed diff --git a/crates/perry-runtime/src/regex/match_all.rs b/crates/perry-runtime/src/regex/match_all.rs index a4a91d99f5..d688418d54 100644 --- a/crates/perry-runtime/src/regex/match_all.rs +++ b/crates/perry-runtime/src/regex/match_all.rs @@ -94,7 +94,33 @@ unsafe fn materialize_match_all_results( let search_str = &str_data[search_start..]; let mut owned: Vec = Vec::new(); - if let Some(fre) = super::lookup_fancy_regex(re) { + if let Some(repeat_matcher) = super::lookup_repeat_matcher(re) { + for matched in repeat_matcher.regex.find_iter(search_str) { + owned.push(OwnedMatchAllData { + groups: matched + .groups() + .map(|group| group.map(|range| search_str[range].to_string())) + .collect(), + named: repeat_matcher + .capture_names + .iter() + .enumerate() + .filter_map(|(index, name)| { + name.as_ref().map(|name| { + ( + name.clone(), + matched + .group(index + 1) + .map(|range| search_str[range].to_string()), + ) + }) + }) + .collect(), + match_index: byte_index_to_utf16_index(str_data, search_start + matched.start()) + as f64, + }); + } + } else if let Some(fre) = super::lookup_fancy_regex(re) { let named_names: Vec<(usize, String)> = fre .capture_names() .enumerate() diff --git a/crates/perry-runtime/src/regex/match_string.rs b/crates/perry-runtime/src/regex/match_string.rs index 9ef4b03f14..dd9f0cd497 100644 --- a/crates/perry-runtime/src/regex/match_string.rs +++ b/crates/perry-runtime/src/regex/match_string.rs @@ -82,7 +82,36 @@ pub extern "C" fn js_string_match( let global = (*re).global; let has_indices = (*re).has_indices; - if let Some(fre) = lookup_fancy_regex(re) { + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + if global { + let matches: Vec = repeat_matcher + .regex + .find_iter(str_data) + .map(|matched| { + OwnedCapture::from_range(str_data, matched.start(), matched.end()) + }) + .collect(); + if matches.is_empty() { + return ptr::null_mut(); + } + OwnedStringMatch::Global(matches) + } else { + let Some(matched) = repeat_matcher.regex.find(str_data) else { + LAST_EXEC_GROUPS.with(|g| *g.borrow_mut() = ptr::null_mut()); + return ptr::null_mut(); + }; + OwnedStringMatch::NonGlobal( + OwnedExecMatch::from_repeat_matcher( + str_data, + 0, + &repeat_matcher, + &matched, + has_indices, + ), + has_indices, + ) + } + } else if let Some(fre) = lookup_fancy_regex(re) { if global { let matches: Vec = fre .find_iter(str_data) diff --git a/crates/perry-runtime/src/regex/repeat_matcher.rs b/crates/perry-runtime/src/regex/repeat_matcher.rs new file mode 100644 index 0000000000..5ce703b71f --- /dev/null +++ b/crates/perry-runtime/src/regex/repeat_matcher.rs @@ -0,0 +1,311 @@ +//! ECMA-262 `RepeatMatcher` compatibility path. +//! +//! Rust's linear `regex` engine intentionally does not implement JavaScript's +//! backtracking capture semantics. In particular, captures nested below a +//! quantified group must be cleared before every iteration, and an optional +//! iteration that matches the empty string must be discarded. Keep the linear +//! engine as the default, but compile patterns where those captures are +//! observable with `regress`, an ECMAScript-native backtracking matcher. + +/// A compiled matcher plus the capture-name ordering that the public `regress` +/// match API does not expose directly. +pub(super) struct RepeatMatcherRegex { + pub(super) regex: regress::Regex, + pub(super) capture_names: Vec>, +} + +impl RepeatMatcherRegex { + fn named_group_range( + &self, + matched: ®ress::Match, + name: &str, + ) -> Option> { + self.capture_names + .iter() + .position(|candidate| candidate.as_deref() == Some(name)) + .and_then(|index| matched.group(index + 1)) + } + + pub(super) fn expand_replacement( + &self, + replacement: &str, + matched: ®ress::Match, + subject: &str, + ) -> String { + let bytes = replacement.as_bytes(); + let group_count = matched.captures.len() + 1; + let has_named_groups = self.capture_names.iter().any(Option::is_some); + let mut out = String::with_capacity(replacement.len() + 16); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'$' { + let start = index; + while index < bytes.len() && bytes[index] != b'$' { + index += 1; + } + out.push_str(&replacement[start..index]); + continue; + } + if index + 1 >= bytes.len() { + out.push('$'); + break; + } + match bytes[index + 1] { + b'$' => { + out.push('$'); + index += 2; + } + b'&' => { + out.push_str(&subject[matched.range()]); + index += 2; + } + b'`' => { + out.push_str(&subject[..matched.start()]); + index += 2; + } + b'\'' => { + out.push_str(&subject[matched.end()..]); + index += 2; + } + b'0'..=b'9' => { + let first = (bytes[index + 1] - b'0') as usize; + let (group, consumed) = + if index + 2 < bytes.len() && bytes[index + 2].is_ascii_digit() { + let two = first * 10 + (bytes[index + 2] - b'0') as usize; + if (1..group_count).contains(&two) { + (Some(two), 2) + } else if (1..group_count).contains(&first) { + (Some(first), 1) + } else { + (None, 0) + } + } else if (1..group_count).contains(&first) { + (Some(first), 1) + } else { + (None, 0) + }; + if let Some(group) = group { + if let Some(range) = matched.group(group) { + out.push_str(&subject[range]); + } + index += 1 + consumed; + } else { + out.push('$'); + index += 1; + } + } + b'<' if has_named_groups => { + if let Some(relative_end) = replacement[index + 2..].find('>') { + let name = &replacement[index + 2..index + 2 + relative_end]; + if let Some(range) = self.named_group_range(matched, name) { + out.push_str(&subject[range]); + } + index += relative_end + 3; + } else { + out.push('$'); + index += 1; + } + } + _ => { + out.push('$'); + index += 1; + } + } + } + out + } + + pub(super) fn replace(&self, subject: &str, replacement: &str, global: bool) -> String { + let mut out = String::new(); + let mut last_end = 0; + for matched in self.regex.find_iter(subject) { + out.push_str(&subject[last_end..matched.start()]); + out.push_str(&self.expand_replacement(replacement, &matched, subject)); + last_end = matched.end(); + if !global { + break; + } + } + out.push_str(&subject[last_end..]); + out + } + + pub(super) fn split(&self, subject: &str, limit: i32) -> Vec> { + let mut out = Vec::new(); + let unbounded = limit < 0; + let push = |out: &mut Vec>, value: Option| -> bool { + out.push(value); + !unbounded && out.len() as i32 >= limit + }; + if subject.is_empty() { + if self.regex.find(subject).is_none() { + out.push(Some(String::new())); + } + return out; + } + + let mut pending_start = 0; + let mut cursor = 0; + while cursor < subject.len() { + let Some(matched) = self.regex.find_from(subject, cursor).next() else { + break; + }; + if matched.start() != cursor { + cursor = matched.start(); + continue; + } + let end = matched.end().min(subject.len()); + if end == pending_start { + cursor += subject[cursor..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(1); + continue; + } + if push(&mut out, Some(subject[pending_start..cursor].to_string())) { + return out; + } + for capture in matched.captures { + let value = capture.map(|range| subject[range].to_string()); + if push(&mut out, value) { + return out; + } + } + pending_start = end; + cursor = end; + } + if unbounded || (out.len() as i32) < limit { + out.push(Some(subject[pending_start..].to_string())); + } + out + } +} + +#[derive(Clone, Copy)] +struct GroupFrame { + captures_before: usize, +} + +fn named_capture_end(bytes: &[u8], open: usize) -> Option { + if bytes.get(open + 1) != Some(&b'?') || bytes.get(open + 2) != Some(&b'<') { + return None; + } + if matches!(bytes.get(open + 3), Some(b'=') | Some(b'!')) { + return None; + } + bytes[open + 3..] + .iter() + .position(|byte| *byte == b'>') + .map(|offset| open + 3 + offset) +} + +fn is_capturing_group(bytes: &[u8], open: usize) -> bool { + bytes.get(open + 1) != Some(&b'?') || named_capture_end(bytes, open).is_some() +} + +fn has_braced_quantifier(bytes: &[u8], mut index: usize) -> bool { + if bytes.get(index) != Some(&b'{') { + return false; + } + index += 1; + let digits_start = index; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + if index == digits_start { + return false; + } + if bytes.get(index) == Some(&b',') { + index += 1; + while bytes.get(index).is_some_and(u8::is_ascii_digit) { + index += 1; + } + } + bytes.get(index) == Some(&b'}') +} + +fn quantifier_follows(bytes: &[u8], index: usize) -> bool { + matches!(bytes.get(index), Some(b'*') | Some(b'+') | Some(b'?')) + || has_braced_quantifier(bytes, index) +} + +/// Return the capture-name layout when a pattern has a capture inside a +/// quantified group. That is precisely the shape for which the linear engine's +/// leftmost-first result can expose stale captures or stop after the wrong +/// nullable iteration. +fn quantified_capture_layout(pattern: &str) -> Option>> { + let bytes = pattern.as_bytes(); + let mut captures = Vec::new(); + let mut groups = Vec::new(); + let mut needs_repeat_matcher = false; + let mut in_class = false; + let mut index = 0; + + while index < bytes.len() { + match bytes[index] { + b'\\' => index = (index + 2).min(bytes.len()), + b'[' if !in_class => { + in_class = true; + index += 1; + } + b']' if in_class => { + in_class = false; + index += 1; + } + b'(' if !in_class => { + let captures_before = captures.len(); + if is_capturing_group(bytes, index) { + let name = named_capture_end(bytes, index) + .map(|end| pattern[index + 3..end].to_string()); + captures.push(name); + } + groups.push(GroupFrame { captures_before }); + index += 1; + } + b')' if !in_class => { + let Some(group) = groups.pop() else { + index += 1; + continue; + }; + if captures.len() > group.captures_before && quantifier_follows(bytes, index + 1) { + needs_repeat_matcher = true; + } + index += 1; + } + _ => index += 1, + } + } + needs_repeat_matcher.then_some(captures) +} + +pub(super) fn compile(pattern: &str, flags: &str) -> Option { + let capture_names = quantified_capture_layout(pattern)?; + let regex = regress::Regex::with_flags(pattern, flags).ok()?; + Some(RepeatMatcherRegex { + regex, + capture_names, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_only_quantified_groups_with_captures() { + assert!(quantified_capture_layout(r"(a?b??)*").is_some()); + assert!(quantified_capture_layout(r"(?:(?=(abc))){0,1}a").is_some()); + assert!(quantified_capture_layout(r"[()]\\(literal\\)").is_none()); + assert!(quantified_capture_layout(r"(?:ab)*").is_none()); + assert!(quantified_capture_layout(r"(ab)c").is_none()); + } + + #[test] + fn records_named_capture_indices() { + assert_eq!( + quantified_capture_layout(r"(?:(?a)(b))*"), + Some(vec![Some("first".to_string()), None]) + ); + } +} diff --git a/crates/perry-runtime/src/regex/replace_expand.rs b/crates/perry-runtime/src/regex/replace_expand.rs index 431dc4944a..e65a19759c 100644 --- a/crates/perry-runtime/src/regex/replace_expand.rs +++ b/crates/perry-runtime/src/regex/replace_expand.rs @@ -265,6 +265,47 @@ pub(super) unsafe fn replace_regex_fn_fancy( replace_fn_run_matches(s_handle, &matches, closure_ptr, has_named_groups) } +pub(super) unsafe fn replace_regex_fn_repeat_matcher( + s_handle: &crate::gc::RuntimeHandle<'_>, + repeat_matcher: &super::repeat_matcher::RepeatMatcherRegex, + global: bool, + closure_ptr: *const crate::closure::ClosureHeader, +) -> *mut StringHeader { + let has_named_groups = repeat_matcher.capture_names.iter().any(Option::is_some); + let str_data = string_as_str(s_handle.get_raw_const_ptr::()); + let mut matches = Vec::new(); + for matched in repeat_matcher.regex.find_iter(str_data) { + matches.push(OwnedMatchData { + start: matched.start(), + end: matched.end(), + char_offset: super::exec_array::byte_index_to_utf16_index(str_data, matched.start()), + groups: matched + .groups() + .map(|group| group.map(|range| str_data[range].to_string())) + .collect(), + named: repeat_matcher + .capture_names + .iter() + .enumerate() + .filter_map(|(index, name)| { + name.as_ref().map(|name| { + ( + name.clone(), + matched + .group(index + 1) + .map(|range| str_data[range].to_string()), + ) + }) + }) + .collect(), + }); + if !global { + break; + } + } + replace_fn_run_matches(s_handle, &matches, closure_ptr, has_named_groups) +} + /// string.replace(regex, replacerFn) — replace with a callback function. /// /// The callback receives the full ECMAScript argument list (#2867): @@ -309,6 +350,15 @@ pub extern "C" fn js_string_replace_regex_fn( .with_const_ptr(|s_now: *const StringHeader| copy_replace_source(s_now)); } + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + return replace_regex_fn_repeat_matcher( + &s_handle, + &repeat_matcher, + global, + closure_ptr, + ); + } + // If the `regex` crate couldn't compile this pattern (lookahead, // backreferences, …), `get_or_compile_regex` stashed a never-match // placeholder in `(*re).regex_ptr` and the real pattern in @@ -414,6 +464,11 @@ pub extern "C" fn js_string_replace_regex_named( } unsafe { + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + let result = repeat_matcher.replace(str_data, repl_str, (*re).global); + return finish_replace_bytes(result.as_bytes()); + } + // Fancy-regex fallback (lookbehind/backreferences): expand `$` // and friends against the fancy captures instead of the never-match // placeholder stored in `regex_ptr`. diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index 0cc718740b..5ce8ea4ee1 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -260,6 +260,62 @@ fn fancy_lookbehind_exec_index() { } } +fn match_capture_text(arr: *const ArrayHeader, index: u32) -> Option { + let value = crate::array::js_array_get_f64(arr, index); + if crate::value::JSValue::from_bits(value.to_bits()).is_undefined() { + return None; + } + let string = crate::value::js_get_string_pointer_unified(value) as *const StringHeader; + Some(string_as_str(string).to_string()) +} + +#[test] +fn repeat_matcher_resets_nested_captures_each_iteration() { + let re = js_regexp_new(make_string(r"(z)((a+)?(b+)?(c))*"), make_string("")); + let matched = js_regexp_exec(re, make_string("zaacbbbcac")); + assert!(!matched.is_null()); + assert_eq!( + (0..6) + .map(|index| match_capture_text(matched, index)) + .collect::>(), + vec![ + Some("zaacbbbcac".to_string()), + Some("z".to_string()), + Some("ac".to_string()), + Some("a".to_string()), + None, + Some("c".to_string()), + ] + ); +} + +#[test] +fn repeat_matcher_discards_empty_optional_iterations() { + let re = js_regexp_new(make_string(r"(a?b??)*"), make_string("")); + let matched = js_regexp_exec(re, make_string("ab")); + assert!(!matched.is_null()); + assert_eq!(match_capture_text(matched, 0).as_deref(), Some("ab")); + assert_eq!(match_capture_text(matched, 1).as_deref(), Some("b")); +} + +#[test] +fn repeat_matcher_clears_captures_when_optional_lookahead_is_skipped() { + for pattern in [r"(?:(?=(abc)))?a", r"(?:(?=(abc))){0,1}a"] { + let re = js_regexp_new(make_string(pattern), make_string("")); + let matched = js_string_match(make_string("abc"), re); + assert!(!matched.is_null(), "{pattern}"); + assert_eq!(match_capture_text(matched, 0).as_deref(), Some("a")); + assert_eq!(match_capture_text(matched, 1), None, "{pattern}"); + } + + for pattern in [r"(?:(?=(abc)))a", r"(?:(?=(abc))){1,1}a"] { + let re = js_regexp_new(make_string(pattern), make_string("")); + let matched = js_string_match(make_string("abc"), re); + assert!(!matched.is_null(), "{pattern}"); + assert_eq!(match_capture_text(matched, 1).as_deref(), Some("abc")); + } +} + #[test] fn test_regexp_test_basic() { let pattern = make_string("hello"); @@ -617,7 +673,7 @@ fn unicode17_scripts_expand_to_codepoint_ranges() { ); } -/// 2026-07-09 GC audit (wave 2 batch A): `REGEX_CACHE`/`FANCY_CACHE` were +/// 2026-07-09 GC audit (wave 2 batch A): the compiled-regex caches were /// unbounded — one entry per distinct `(pattern, flags)` ever compiled, up to /// 64 MiB each — so `new RegExp(userInput)` was an attacker-driven OOM. The /// caches are now capped (clear-on-overflow) and every `RegExpHeader` OWNS a @@ -633,6 +689,11 @@ fn regex_cache_capped_and_prior_headers_survive_eviction() { let fancy = js_regexp_new(make_string(r"(?<=pre)\d+"), make_string("")); assert!(js_regexp_test(fancy, make_string("pre77")) != 0); + // A RepeatMatcher header whose ECMAScript matcher must likewise outlive + // its thread-local cache entry. + let repeat_matcher = js_regexp_new(make_string(r"(a?b??)*"), make_string("")); + assert!(js_regexp_test(repeat_matcher, make_string("ab")) != 0); + // Flood the cache with distinct patterns — far past the cap. for i in 0..(REGEX_CACHE_MAX_ENTRIES * 2 + 10) { let _ = get_or_compile_regex(&format!("cachefill{i}[a-z]+"), ""); @@ -653,6 +714,16 @@ fn regex_cache_capped_and_prior_headers_survive_eviction() { "FANCY_CACHE must stay capped at {REGEX_CACHE_MAX_ENTRIES} entries, got {fancy_len}" ); + // Quantified captures populate the ECMAScript RepeatMatcher cache. + for i in 0..(REGEX_CACHE_MAX_ENTRIES + 10) { + let _ = get_or_compile_regex(&format!("(repeat{i})*"), ""); + } + let repeat_len = REPEAT_MATCHER_CACHE.with(|c| c.borrow().len()); + assert!( + repeat_len <= REGEX_CACHE_MAX_ENTRIES, + "REPEAT_MATCHER_CACHE must stay capped at {REGEX_CACHE_MAX_ENTRIES} entries, got {repeat_len}" + ); + // The pre-flood headers still execute correctly: their compiled programs // are owned by the headers (leaked Arc refs), not borrowed from the // now-cleared caches. @@ -673,6 +744,10 @@ fn regex_cache_capped_and_prior_headers_survive_eviction() { js_regexp_test(fancy, make_string("nope77")) == 0, "fancy-fallback header must keep rejecting after cache eviction" ); + assert!( + js_regexp_test(repeat_matcher, make_string("ab")) != 0, + "RepeatMatcher header must keep matching after cache eviction" + ); } // ---------------------------------------------------------------------------