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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/8660-regexp-repeat-matcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed RegExp quantified capture groups to follow ECMA-262 RepeatMatcher semantics, including clearing skipped captures and discarding empty optional iterations.
7 changes: 4 additions & 3 deletions crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"] }
Expand Down
87 changes: 83 additions & 4 deletions crates/perry-runtime/src/regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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
Expand All @@ -301,6 +304,11 @@ crate::perry_thread_local! {
static REGEX_CACHE: RefCell<HashMap<(String, String), Arc<Regex>>> = RefCell::new(HashMap::new());
/// Fancy-regex fallback cache for patterns with lookbehind/lookahead.
static FANCY_CACHE: RefCell<HashMap<(String, String), Arc<fancy_regex::Regex>>> = 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<HashMap<(String, String), Arc<repeat_matcher::RepeatMatcherRegex>>> = RefCell::new(HashMap::new());
}

/// Compiled-program size budget handed to both regex engines.
Expand Down Expand Up @@ -343,13 +351,13 @@ pub(crate) fn build_fancy_regex(pattern: &str) -> Result<fancy_regex::Regex, fan
.build()
}

/// Entry cap for `REGEX_CACHE`/`FANCY_CACHE` (2026-07-09 GC audit: one entry
/// Entry cap for the compiled-regex caches (2026-07-09 GC audit: one entry
/// per distinct `(pattern, flags)` ever compiled, no cap of any kind, entries
/// up to [`REGEX_SIZE_LIMIT`] — `new RegExp(userInput)` was an attacker-driven
/// OOM). When an insert would exceed the cap the whole map is cleared — the
/// `PARSE_KEY_CACHE` precedent: cheap, no LRU bookkeeping, recompilation is
/// the fallback. Live `RegExpHeader`s are unaffected: each header OWNS a
/// leaked `Arc` reference to its compiled program(s) (`regex_ptr`/`fancy_ptr`),
/// leaked `Arc` reference to its compiled program(s),
/// so dropping the cache's references cannot free a program still in use.
#[cfg(feature = "regex-engine")]
const REGEX_CACHE_MAX_ENTRIES: usize = 512;
Expand Down Expand Up @@ -388,6 +396,16 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> 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');
Expand Down Expand Up @@ -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<RepeatMatcherRegex>` 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1175,6 +1214,31 @@ pub(crate) fn lookup_fancy_regex(re: *const RegExpHeader) -> Option<Arc<fancy_re
}
}

/// Look up the ECMAScript-native matcher used when quantified capture groups
/// make `RepeatMatcher`'s capture-reset semantics observable.
#[cfg(feature = "regex-engine")]
fn lookup_repeat_matcher(
re: *const RegExpHeader,
) -> Option<Arc<repeat_matcher::RepeatMatcherRegex>> {
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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Option<String>> = if let Some(fre) = lookup_fancy_regex(re) {
let parts: Vec<Option<String>> = 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.)
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions crates/perry-runtime/src/regex/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<RegExpHeader, _>(|| js_string_from_str(flags_str));
let canonical_flags_handle = scope.root_string_ptr(canonical_flags_ptr);
Expand All @@ -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).
Expand All @@ -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');
Expand Down
25 changes: 24 additions & 1 deletion crates/perry-runtime/src/regex/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
35 changes: 35 additions & 0 deletions crates/perry-runtime/src/regex/exec_array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: &regress::Match,
has_indices: bool,
) -> Self {
let captures: Vec<Option<OwnedCapture>> = 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
Expand Down
28 changes: 27 additions & 1 deletion crates/perry-runtime/src/regex/match_all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,33 @@ unsafe fn materialize_match_all_results(
let search_str = &str_data[search_start..];

let mut owned: Vec<OwnedMatchAllData> = 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()
Expand Down
Loading
Loading