diff --git a/Cargo.lock b/Cargo.lock index feb67adf1f..6aba8812cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4835,9 +4835,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cce9fa3a84ee8c8c49873b937167a284f5446a67ace8eeac986bf9a97592f01e" +checksum = "51a7ee1a53a0fbe25a8b9f5be8ba3fbee87aa6ecca0fda04c99d24d8b9b386da" [[package]] name = "perry" diff --git a/Cargo.toml b/Cargo.toml index 39ac06bc54..995480b5ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -430,24 +430,13 @@ regex = "1.12" aho-corasick = "1.1" memchr = "2" # The single regular-expression engine, through crates/perry-perex. -# 0.1.13 adds perex::dfa, a lazy deterministic automaton over host-owned cache -# storage that answers whether and where a program matches without -# backtracking (program format 15: word 7 bit 25 marks eligible programs; -# release commit c13fdc92af, per the crate's .cargo_vcs_info.json). Perry keeps -# one cache in each program cell (regex/perex_owner.rs) and asks it for a -# match's bounds before the evaluator runs (regex/perex_runtime.rs). It is -# younger than the 7-day `global-min-publish-age` soak (.cargo/config.toml), so -# Cargo.lock was resolved once with CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow -# under an owner-approved one-time publish-age override (2026-10-09), for this -# version only; the window itself is unchanged. No public API change from -# 0.1.12 beyond the program format. -# sha256 cce9fa3a84ee8c8c49873b937167a284f5446a67ace8eeac986bf9a97592f01e — the -# checksum Cargo.lock records, verified against -# https://crates.io/api/v1/crates/perex/0.1.13. As with turnloop above, the -# opt-out is a committed, reviewable change, never a silent one, and the lock -# pins the exact artifact. The override lapses on its own on 2026-10-16, when -# 0.1.13 clears the window. -perex = "0.1.13" +# 0.1.14 adds exact bounded start-anchored literal execution (format 16): +# immutable program instructions are compared with the original subject, +# without a DFA cache, matcher scratch, or generated per-pattern native code. +# Resolved once on 2026-10-18 with CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow +# before this release clears the 7-day publish-age window. Cargo.lock pins the +# verified registry artifact; the global window is unchanged. +perex = "0.1.14" # turnloop P0/P1: the per-agent event-loop wait driver and, since P1, Perry's # stream sockets (docs/turnloop/p0-report.md, crates/perry-runtime/src/turnloop_net). # 0.1.0-alpha.3 switched turnloop's own dependency requirements from exact pins diff --git a/changelog.d/12792-perex-anchored-literal.md b/changelog.d/12792-perex-anchored-literal.md new file mode 100644 index 0000000000..2facbf8bc4 --- /dev/null +++ b/changelog.d/12792-perex-anchored-literal.md @@ -0,0 +1 @@ +Start-anchored literal regular expressions use Perex 0.1.14's exact prefix executor over immutable program data, avoiding DFA state and evaluator scratch. Prefix programs no longer carry an unused DFA cache. Literal sites continue to share matcher data while each evaluation has its own RegExp object and lastIndex. diff --git a/crates/perry-runtime/src/regex/perex_owner.rs b/crates/perry-runtime/src/regex/perex_owner.rs index f4d8f65c53..79bdd436ee 100644 --- a/crates/perry-runtime/src/regex/perex_owner.rs +++ b/crates/perry-runtime/src/regex/perex_owner.rs @@ -49,6 +49,7 @@ const DFA_MAX_WORDS: usize = 64 * 1024; /// minimum plus [`DFA_STATE_WORDS`]. A property of the program alone. fn dfa_cache_words(program: perex::program::Program<'_>) -> usize { match perex::dfa::minimum_words(program) { + Some(0) => 0, // Exact literal prefixes need no automaton storage. Some(minimum) if minimum <= DFA_MAX_WORDS => minimum + DFA_STATE_WORDS, _ => 0, } diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index 0866411dfe..e491656e11 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -327,6 +327,7 @@ crate::perry_thread_local! { /// Test-only: how many searches the lazy automaton decided (a match's /// bounds or no match), so a test can assert the automaton answered. pub(crate) static DFA_ANSWERS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub(crate) static LITERAL_PREFIX_ANSWERS: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[cfg(test)] @@ -495,7 +496,7 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( /// the start of its last attempt (#10164), or `None` when the lazy automaton /// answered without the evaluator. On non-ASCII storage a search from an end /// costs up to half the subject, so a loop of them is quadratic; such a loop -/// is the evaluator's, which reports positions (see [`dfa_bounds`]). +/// is the evaluator's, which reports positions (see [`program_bounds`]). /// /// `near` must come from a search or reader over this same binding. Another /// string with an identical layout cannot be detected and would give wrong @@ -897,7 +898,8 @@ fn tries_one_start(words: &[u32]) -> bool { .is_some_and(|w| w & PROGRAM_START_ANCHORED != 0) } -/// Ask the program cell's lazy automaton (`perex::dfa`) for the bounds of the +/// Ask Perex's exact literal-prefix executor first, then the program cell's +/// lazy automaton (`perex::dfa`), for the bounds of the /// match at or after `start`. The automaton and the evaluator are two exact /// readings of the same program; this only chooses which one answers. /// @@ -910,7 +912,7 @@ fn tries_one_start(words: &[u32]) -> bool { /// that tries a single start (the evaluator then runs one attempt either way, /// so bounds first would only add work). Neither allocates nor collects. #[inline(always)] -fn dfa_bounds( +fn program_bounds( resources: &BoundResources<'_, P, S>, start: usize, follows: bool, @@ -924,6 +926,14 @@ where S::Error: HostResourceError, { let answered = resources.with_views(|program, input| { + if let Some(prefix) = program.literal_prefix() { + #[cfg(test)] + LITERAL_PREFIX_ANSWERS.with(|n| n.set(n.get() + 1)); + return prefix + .find(input, start, budget) + .map(|span| span.map_or(Bounds::NoMatch, |span| Bounds::Match(span, false))) + .map_err(EngineError::Execution); + } // SAFETY: `P: CellProgram`, so these are a program cell's words; the // cache is used only inside this view, by this one call. let Some(cache) = (unsafe { super::perex_owner::cell_dfa_cache(program.words()) }) else { @@ -936,7 +946,12 @@ where if matches!(mode, CaptureMode::All) && groups && tries_one_start(program.words()) { return Ok(Bounds::Declined); } - match dfa::find(program, input, start, cache, budget) { + let found = dfa::find(program, input, start, cache, budget); + #[cfg(test)] + if matches!(found, Ok(dfa::Found::Match(_) | dfa::Found::NoMatch)) { + DFA_ANSWERS.with(|n| n.set(n.get() + 1)); + } + match found { Ok(dfa::Found::Match(span)) => Ok(Bounds::Match(span, groups)), Ok(dfa::Found::NoMatch) => Ok(Bounds::NoMatch), Ok(dfa::Found::Declined(_)) => Ok(Bounds::Declined), @@ -979,11 +994,7 @@ where S::Error: HostResourceError, H: PollRoots, { - let answer = dfa_bounds(resources, start, follows, mode, budget)?; - #[cfg(test)] - if !matches!(answer, Bounds::Declined) { - DFA_ANSWERS.with(|n| n.set(n.get() + 1)); - } + let answer = program_bounds(resources, start, follows, mode, budget)?; let (start, bounds) = match answer { Bounds::NoMatch => return Ok((None, None)), Bounds::Match(span, groups) => match mode { diff --git a/crates/perry-runtime/src/regex/perex_runtime/dfa_tests.rs b/crates/perry-runtime/src/regex/perex_runtime/dfa_tests.rs index 60a9b2d1be..2a19375ba0 100644 --- a/crates/perry-runtime/src/regex/perex_runtime/dfa_tests.rs +++ b/crates/perry-runtime/src/regex/perex_runtime/dfa_tests.rs @@ -190,3 +190,48 @@ fn dfa_decisions_do_not_acquire_evaluator_metadata() { assert_eq!(automaton, evaluator); assert!(EVALUATOR_METADATA_READS.with(std::cell::Cell::get) > before); } + +/// Prefix programs are answered with no automaton storage or evaluator setup. +#[test] +fn literal_prefixes_need_neither_dfa_cache_nor_evaluator_scratch() { + let _lock = crate::gc::global_side_table_test_lock(); + let prefix_before = LITERAL_PREFIX_ANSWERS.with(std::cell::Cell::get); + let dfa_before = DFA_ANSWERS.with(std::cell::Cell::get); + let metadata_before = EVALUATOR_METADATA_READS.with(std::cell::Cell::get); + for (pattern, flags, subject) in [ + ("^--", "", "--option"), + ("^--", "g", "x--option"), + ("^no-", "y", "no-color"), + ("^é", "u", "éclair"), + ("^😀", "gu", "😀tail"), + ] { + let scope = RuntimeHandleScope::new(); + let re = scope.root_raw_mut_ptr(regexp(&scope, pattern, flags)); + let owner = re + .with_const_ptr::(|re| unsafe { GcProgram::from_regexp(&scope, re) }) + .unwrap(); + let program = super::super::perex_api::bind_program(owner, &mut Budget::new(WORK)).unwrap(); + program + .with_view(|p| { + assert!(p.literal_prefix().is_some()); + assert_eq!(dfa::minimum_words(p), Some(0)); + assert!(unsafe { super::super::perex_owner::cell_dfa_cache(p.words()) }.is_none()); + }) + .unwrap(); + for start in 0..=subject.encode_utf16().count() + 1 { + for mode in [CaptureMode::Full, CaptureMode::All] { + let (prefix, evaluator) = both(pattern, flags, subject, start, mode); + assert_eq!( + prefix, evaluator, + "/{pattern}/{flags} {subject:?} start={start}" + ); + } + } + } + assert!(LITERAL_PREFIX_ANSWERS.with(std::cell::Cell::get) > prefix_before); + assert_eq!(DFA_ANSWERS.with(std::cell::Cell::get), dfa_before); + assert_eq!( + EVALUATOR_METADATA_READS.with(std::cell::Cell::get), + metadata_before + ); +} diff --git a/test-files/test_gap_regexp_anchored_literal_prefix.ts b/test-files/test_gap_regexp_anchored_literal_prefix.ts new file mode 100644 index 0000000000..ddaf7eecd3 --- /dev/null +++ b/test-files/test_gap_regexp_anchored_literal_prefix.ts @@ -0,0 +1,38 @@ +// Exact anchored literals share program data but keep per-evaluation state. +for (const flags of ["", "g", "y", "gy", "u", "gu", "yu", "m", "gm", "i", "gi"]) { + const re = new RegExp("^--", flags); + for (const s of ["--option", "x--option", "\n--option", "-", ""]) { + re.lastIndex = 1; + console.log(flags, JSON.stringify(s), JSON.stringify(s.replace(re, "[$&]")), re.lastIndex); + re.lastIndex = 0; + console.log(flags, JSON.stringify(s.replace(re, (match, offset) => match + ":" + offset)), re.lastIndex); + } +} +for (const pattern of ["^é", "^😀", "^\\ud800"]) { + for (const flags of ["", "u", "gu", "yu", "m"]) { + const re = new RegExp(pattern, flags); + for (const s of ["éclair", "😀tail", "\ud800tail", "\n😀tail"]) { + re.lastIndex = 1; + console.log(pattern, flags, JSON.stringify(s.replace(re, "<$&>")), re.lastIndex); + } + } +} +const saved: RegExp[] = []; +for (let i = 0; i < 4; i++) { + const re = /^--/g; + console.log("fresh", i, re.lastIndex, "--x".replace(re, "")); + re.lastIndex = i + 2; + saved.push(re); +} +console.log("distinct", saved[0] !== saved[1], saved.map(r => r.lastIndex).join(",")); +console.log("templates", "--option".replace(/^--/, "$$:$&:$`:$':$1")); +console.log("captured", "--option".replace(/^(--)/, "<$1>")); +console.log("multiline", "x\n--option".replace(/^--/m, "")); +const changing = /^--/g; +console.log("callback", "--option".replace(changing, (match, offset, source) => { + changing.lastIndex = 7; + return match + ":" + offset + ":" + source; +}), changing.lastIndex); +const overridden: any = /^--/; +overridden.exec = () => null; +console.log("ownexec", "--option".replace(overridden, "gone"));