Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Cargo.lock

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

25 changes: 7 additions & 18 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/12792-perex-anchored-literal.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions crates/perry-runtime/src/regex/perex_owner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
29 changes: 20 additions & 9 deletions crates/perry-runtime/src/regex/perex_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = const { std::cell::Cell::new(0) };
pub(crate) static LITERAL_PREFIX_ANSWERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

#[cfg(test)]
Expand Down Expand Up @@ -495,7 +496,7 @@ pub(crate) fn find<'mem, S: ImmutableSubject<Error = OwnerError>>(
/// 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
Expand Down Expand Up @@ -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.
///
Expand All @@ -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<P, S>(
fn program_bounds<P, S>(
resources: &BoundResources<'_, P, S>,
start: usize,
follows: bool,
Expand All @@ -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 {
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions crates/perry-runtime/src/regex/perex_runtime/dfa_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<RegExpHeader, _>(|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
);
}
38 changes: 38 additions & 0 deletions test-files/test_gap_regexp_anchored_literal_prefix.ts
Original file line number Diff line number Diff line change
@@ -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"));
Loading