From 4c6479d215a21382e2132cd5a7440a70e5db00a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 8 Oct 2026 17:36:02 +0000 Subject: [PATCH] Release 0.1.13: a lazy automaton answers match and bounds from host-owned storage A new module, dfa, answers whether a program matches (is_match) and the span of group zero (find) with a deterministic automaton built while it searches: states from the program's instructions on first visit, transitions over character classes found lazily, all in u32 storage the host allocates, sizes, may move between calls and keeps one of per program. Nothing is generated and no program grows beyond one header bit. Leftmost-first priority is kept by ordered thread lists (the evaluator's exploration order, with repeat counters and empty-iteration bits in a 32-bit key); the start comes from a reverse pass over the same instructions. A full cache clears and continues; three thrashing clears disable it until reset. Captures still come from the evaluator, started at the automaton's start. From the start state with no context, the steady path skips with the evaluator's own candidate scans: the leading claim's byte pairs, or the first-character descriptor's bytes four words to a branch. Over WTF-8 it passes over non-ASCII characters whose cached transition keeps the start state, and over all of them when the cache's build found that no first instruction accepts one; UTF-16 positions are counted only for a match end. The reverse pass has a steady path over ASCII storage. Program format 15: word 7 bit 25 marks programs the automaton decides exactly (no backreferences, lookaround or properties of strings, repeat counters that fit), derived by the compiler and re-derived on validation. Format 14 is rejected. The evaluator's answers and costs are unchanged. AGENTS.md and CLAUDE.md: one semantics, two exact executors. The automaton answers exactly what the evaluator answers, enforced by the differential and fuzz CI with the automaton off, roomy and at its minimum cache, and declines rather than approximates; captures always come from the evaluator. Per search (callgrind, warm-up excluded, binding of 129 included), 0.1.12 test -> is_match: /^\p{DI}$/u on one-character graphemes 385 -> 291, the emoji-regex pattern 940 -> 292 (on a digit 2,561 -> 292, on an emoji 5,534 -> 424), /ab|cd|ef|gh/ 2,020 -> 324, /a/ 400 -> 292; long scans /\d{3}-\d{4}/ 64 KB ASCII 359,130 -> 176,939, WTF-8 359,437 -> 236,940, /zq|qz|xj/ 64 KB ASCII 227,145 -> 215,816, WTF-8 14.2 M -> 215,898. No measured case costs more than the evaluator. See docs/dfa.md. --- .github/workflows/ci.yml | 15 + AGENTS.md | 2 +- CLAUDE.md | 5 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 5 + bench/Cargo.lock | 2 +- docs/candidate.md | 6 +- docs/dfa.md | 346 ++++++ docs/engine.md | 2 +- docs/leading.md | 2 +- examples/engine_probe.rs | 130 +++ src/compiler/prepared.rs | 5 + src/dfa.rs | 2312 +++++++++++++++++++++++++++++++++++++ src/executor.rs | 2 +- src/executor/candidate.rs | 292 +++-- src/lib.rs | 1 + src/program.rs | 96 +- tests/admission.rs | 3 +- tests/atom_repeat.rs | 2 +- tests/branch.rs | 2 +- tests/candidate.rs | 12 +- tests/classes.rs | 2 +- tests/dfa.rs | 555 +++++++++ tests/modifiers.rs | 2 +- tools/check-dfa-fuzz.mjs | 180 +++ 26 files changed, 3851 insertions(+), 134 deletions(-) create mode 100644 docs/dfa.md create mode 100644 src/dfa.rs create mode 100644 tests/dfa.rs create mode 100644 tools/check-dfa-fuzz.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b1319d..4d3fc0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,16 @@ jobs: input-differential: runs-on: ubuntu-latest + # Every harness below runs three times: with the evaluator alone, and with + # the lazy automaton answering every eligible case beside it, in a roomy + # cache and in the least storage each program accepts. Any disagreement is + # an outcome of its own. See docs/dfa.md. + strategy: + fail-fast: false + matrix: + dfa: ["", "1", "small"] + env: + PEREX_DFA: ${{ matrix.dfa }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -129,6 +139,11 @@ jobs: run: node tools/check-test262.mjs target/release/examples/engine_probe third_party/test262-checkout artifacts/test262 --work 64000000 - name: Unicode and legacy case equivalence differential run: node tools/check-casefold.mjs target/release/examples/engine_probe + - name: Random automaton-eligible patterns against Node + if: matrix.dfa != '' + run: | + node tools/check-dfa-fuzz.mjs target/release/examples/engine_probe artifacts/dfa-fuzz-1 --seed 1 --cases 20000 + node tools/check-dfa-fuzz.mjs target/release/examples/engine_probe artifacts/dfa-fuzz-2 --seed 2 --cases 20000 - run: cargo +stable build --locked --release --example property_probe - name: Exhaustive properties and exact reviewed reference disagreements run: node tools/check-properties.mjs target/release/examples/property_probe target/release/examples/engine_probe --allow-reviewed-reference-disagreements diff --git a/AGENTS.md b/AGENTS.md index 316787d..82a8385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Perex development constraints - This repository is the authoritative reusable engine source; it must build independently of Perry. -- Implement one production compiler/matcher. Other engines may be development references, never production fallbacks. +- One semantics, two exact executors. One compiler emits one program format. The backtracking evaluator defines every answer and is the only source of captures. The lazy automaton (`src/dfa.rs`) may answer match/no-match and group zero's bounds for programs whose validated header bit says it decides them exactly; it answers exactly what the evaluator answers, which the differential and fuzz CI enforce in all three automaton modes (off, roomy cache, minimum cache), and it declines rather than approximates. Other engines may be development references, never production fallbacks. - Follow `docs/memory-contract.md`: explicit storage ownership, relocation-safe programs, scoped scratch, exact string semantics and offset-only results. - The compiler/matcher, input layer and resumable execution are implemented and are Perry's production regex engine. The `v` flag is implemented in full, properties of strings included. Do not claim matching, compatibility or performance that has not been measured. - Traverse the host's original subject without copying it or creating a UTF-16 conversion buffer. Integer spans remain the result contract. diff --git a/CLAUDE.md b/CLAUDE.md index f732396..b004c71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,11 +53,11 @@ Cost drivers (diagnostics, not adoption evidence): `examples/input_cost.rs`, `en ## Architecture -One compiler, one program format, one evaluator. There is no fallback engine and must never be one. +One compiler, one program format, one semantics with two exact executors: the evaluator, which defines every answer and produces every capture, and the lazy automaton, which answers match/no-match and group zero's bounds for eligible programs exactly as the evaluator would, or declines. There is no approximating or fallback engine and must never be one. **Pattern text → program.** `compiler::compile` parses into a caller-owned arena of `Node`/`Range` scratch (indices only, no source pointers survive) and emits a validated immutable `u32` program. `compiler::prepare` splits the same pipeline so the host can drop its pattern borrow before allocating final output: `Prepared::required_words()` gives the exact size, `emit` consumes the plan. Emission walks the topologically ordered arena iteratively — no native-stack recursion on long concatenations, no per-count expansion of `a{n}`. Submodules under `src/compiler/` add optimizer analyses (`admission`, `candidate`, `classes`, `repetition`) and grammar corners (`escapes` for Annex B, `names`). -**Program format** (`src/program.rs`). One relocatable buffer: header, three words per instruction, class-table pairs, eight-word repeat records, a filter section for properties of strings, optional packed name section. Everything is relative offsets/indices — never a host pointer. `Program::from_words` revalidates opcodes, operand ranges, property IDs, branch/table bounds and name/capture lists under a work budget, so a relocated or re-read buffer is always checked. `HEADER` and `VERSION` in `src/program.rs` are authoritative (currently 11 words / version 14); older versions are rejected outright, and a few docs still quote an earlier number. Word 9 (leading-literal run) is *re-derived* from the instructions during validation rather than trusted — hint words that can't be re-derived (words 7/8) are only allowed to be conservative, never to supply capture semantics. +**Program format** (`src/program.rs`). One relocatable buffer: header, three words per instruction, class-table pairs, eight-word repeat records, a filter section for properties of strings, optional packed name section. Everything is relative offsets/indices — never a host pointer. `Program::from_words` revalidates opcodes, operand ranges, property IDs, branch/table bounds and name/capture lists under a work budget, so a relocated or re-read buffer is always checked. `HEADER` and `VERSION` in `src/program.rs` are authoritative (currently 11 words / version 15); older versions are rejected outright, and a few docs still quote an earlier number. Word 9 (leading-literal run) is *re-derived* from the instructions during validation rather than trusted — hint words that can't be re-derived (words 7/8) are only allowed to be conservative, never to supply capture semantics. **Subject access** (`src/input.rs`). `Input` borrows original storage — `&str`, generalized WTF-8 bytes (lone and separately-encoded surrogates accepted), or host `&[u16]`. **Never build a UTF-16 conversion buffer or copy the subject**; that is the core project constraint. All public positions count UTF-16 units, including positions between surrogate halves. ASCII byte storage gets a distinct internal representation with O(1) seeks; non-ASCII byte cursors pack a half-pair flag into the high bit of the byte offset. @@ -79,6 +79,7 @@ A single `Budget` covers a whole compile or search — it is never reset per sta - Keep the three outcomes distinct: syntax error, `CompileError::Unsupported`, and no-match. Reporting an unimplemented feature as a syntax error or a no-match is a correctness bug, and unsupported cases must stay visible rather than being counted as handled. - Matching changes need complete-capture witnesses; memory changes need lifetime/relocation witnesses (relocate, poison and free the old storage, then continue). Each `docs/.md` ends with a Verification section naming its tests and harnesses — update the doc with the code. - Optimizations (admission, candidate starts, leading literals, atom repeats, sorted classes) must run the same VM for the answer. They may only reject impossible work; they never choose between matches or produce captures. +- The lazy automaton (`src/dfa.rs`, `docs/dfa.md`) is the second exact executor of the same semantics (`AGENTS.md`): for programs whose validated word 7 bit says it decides them exactly, it answers match/no-match and group zero's bounds from the same instructions. It never produces captures, it declines rather than approximates, and the probe's `PEREX_DFA` mode (CI runs every differential harness off, with `1` and with `small`) and `tools/check-dfa-fuzz.mjs` compare it with the VM and Node. A change to either must keep them equal. Its idle skip reuses the evaluator's candidate scans and leading pairs (`src/executor/candidate.rs`); like them, it may pass over only positions where no match can begin. - Do not claim compatibility, conformance percentages or performance that has not been measured, and preserve failed measurements rather than deleting them. `docs/performance.md` defines the per-case CPU/RSS gates; aggregate wins do not satisfy them. - New dependencies, `unsafe`, implicit global caches, engine-owned result strings and host GC assumptions are all out of bounds. Perry-specific GC/adapter code belongs in Perry, not here. - Bumping the program format means rejecting older versions and updating both the validator and the docs that quote the layout. diff --git a/Cargo.lock b/Cargo.lock index 16cf11c..95f1557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "perex" -version = "0.1.12" +version = "0.1.13" diff --git a/Cargo.toml b/Cargo.toml index 485b664..f4b77df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "perex" -version = "0.1.12" +version = "0.1.13" edition = "2024" rust-version = "1.88" description = "An ECMAScript regex engine designed for explicit host memory ownership" diff --git a/README.md b/README.md index b19b624..b74c821 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,9 @@ node tools/check-repetition.mjs target/release/examples/engine_probe node tools/check-atom-filter.mjs target/release/examples/engine_probe artifacts/atom-filter node tools/check-atom-filter.mjs target/release/examples/engine_probe artifacts/atom-filter-q1 --quantum 1 --relocate --grow node tools/check-atom-filter.mjs target/release/examples/engine_probe artifacts/atom-filter-q17 --quantum 17 --relocate --grow +PEREX_DFA=1 node tools/check-engine.mjs target/release/examples/engine_probe --allow-listed-unsupported --allow-reviewed-reference-disagreements +PEREX_DFA=small node tools/check-engine.mjs target/release/examples/engine_probe --allow-listed-unsupported --allow-reviewed-reference-disagreements +node tools/check-dfa-fuzz.mjs target/release/examples/engine_probe artifacts/dfa-fuzz --seed 1 --cases 20000 node tools/check-modifiers.mjs target/release/examples/engine_probe node tools/check-unicode-sets.mjs target/release/examples/engine_probe artifacts/unicode-sets node tools/check-sets.mjs target/release/examples/engine_probe artifacts/sets --allow-reviewed-reference-disagreements @@ -177,6 +180,8 @@ Case equivalence uses generated Unicode 17.0.0 data under the [Unicode License V [Test262 pattern conformance](docs/conformance.md) compares every pattern harvested from the suite's regular-expression tests against Node, for syntax acceptance and complete match answers. There is no remaining gap: 48,718 cases compared, nothing unsupported and no difference, on the harvest recorded there. +[The lazy automaton](docs/dfa.md) answers whether a program matches, and where its match begins and ends, in a few instructions per character from states it builds on demand in a cache the host owns, for programs without backreferences, lookaround or properties of strings. The evaluator remains the only source of captures, and every differential harness compares the two. + [Branches](docs/branch.md) lay alternation out flat and skip an alternative, and its backtracking frame, when the next character cannot begin it, by a first-character set re-derived from the instructions during validation. [Leading literal starts](docs/leading.md) reject an impossible start with a byte comparison instead of an initialized trial. The claim is re-derived from the instructions during program validation, so it cannot disagree with them. diff --git a/bench/Cargo.lock b/bench/Cargo.lock index 5c77dc8..5e2cb72 100644 --- a/bench/Cargo.lock +++ b/bench/Cargo.lock @@ -80,7 +80,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "perex" -version = "0.1.12" +version = "0.1.13" [[package]] name = "perex-bench" diff --git a/docs/candidate.md b/docs/candidate.md index 6d0bdc9..7f139cb 100644 --- a/docs/candidate.md +++ b/docs/candidate.md @@ -36,7 +36,8 @@ no ASCII start is possible, or `2 | (lo << 8) | (hi << 16)` for an inclusive interval with `0 <= lo <= hi <= 127`. Reserved bits, malformed bounds and old format versions are rejected. The additional word costs four used program bytes. Since format 14 the descriptor occupies word 7's low 24 bits; bit 24 holds the -validated start-anchored claim and the bits above it are reserved. +validated start-anchored claim. Since format 15 bit 25 holds the validated +[automaton eligibility](dfa.md) claim, and the bits above it are reserved. Bindings check the full eleven-word header when reacquiring a view. AOT and runtime programs must use this same format and its version checks. @@ -65,7 +66,8 @@ positions; [leading](leading.md) describes it. Only logical positions through the first candidate are charged, so word-read speculation does not change matching work across quanta. Subject positions remain cursor offsets/checkpoints; no subject address survives the scoped borrow. The scan adds a phase but no frame, -undo entry or match-scratch buffer. +undo entry or match-scratch buffer. The lazy automaton's idle skip uses these same scans and +leading pairs ([lazy DFA](dfa.md#the-idle-skip)). Mixed scanning uses the cursor's current byte suffix, never a UTF-16 re-seek from an end of the string. It checks the skipped prefix is ASCII and advances byte and diff --git a/docs/dfa.md b/docs/dfa.md new file mode 100644 index 0000000..cd339eb --- /dev/null +++ b/docs/dfa.md @@ -0,0 +1,346 @@ +# Lazy DFA + +`perex::dfa` answers whether a program matches, and where its match begins +and ends, by walking a deterministic automaton built while it searches. The +automaton is data in storage the host owns. It is built from the program's +instructions one state at a time, only for states a search reaches, and only +for programs that run. No program gains words beyond one header bit, and no +code is generated. + +The backtracking evaluator stays the definition of every answer and the only +source of captures. The automaton is a second reading of the same instructions +for the subset whose meaning a finite automaton can hold exactly, and the +differential suites compare the two on every case they run (see Verification). + +## Eligibility + +Format 15 adds one bit to header word 7, `DFA` (bit 25). The compiler sets it +and `Program::from_words` re-derives it from the instructions and rejects a +program whose bit disagrees, as it does for the start-anchored claim, so a +program can neither claim nor hide eligibility. + +A program is eligible when every instruction is one of: + +- characters, classes (plain, folded, sorted, properties and their `u`/`v` + complements), `.` with and without `s`; +- capture bookkeeping (`SAVE`), `SPLIT`, `BRANCH`, `JUMP`, `MATCH`; +- `^`, `$` with and without `m`, `\b`, `\B`, folded or not; +- repeats (`REPEAT_INIT/CHOICE/BODY/NEXT`, `ATOM_REPEAT`) in the shape the + compiler emits, whose counters fit (below). + +Backreferences, lookahead and lookbehind, and properties of strings (which +consume more than one character per instruction) make a program ineligible. +Their programs are searched by the evaluator alone, exactly as before. + +A repeat's counter is part of the automaton's state, so it must be bounded. A +repeat with a maximum counts up to it; an unbounded one counts only up to its +minimum, since every count from there on behaves the same. Each repeat needs +the bits of that bound plus one bit for the empty-iteration check, at most +1023 per bound, and the repeats enclosing any one instruction must fit together +in 32 bits. Repeats that do not enclose each other share bits. + +## States and threads + +A thread is an instruction index and a 32-bit key holding the counters and +empty-check bits of the repeats around it. A forward state is an ordered list +of threads: those that have just consumed the character before the current +position, in the order the evaluator would try them. It also records what is +known about that character — whether it is a line terminator, a word character +with and without Unicode folding, or absent at the subject's start — and +whether new starts are still being added. + +The transition on the next character (or the end of the subject) takes the +state's threads in order and follows every non-consuming instruction depth +first, first arm first, as the evaluator does: + +- `SPLIT`, `BRANCH`, `JUMP`, `SAVE`: both arms in order, the jump, nothing. + `BRANCH` is followed as a `SPLIT`; its first-character set only skips work. +- Assertions are decided here, with the previous character known from the + state and the next character known from the transition. +- `REPEAT_CHOICE` reads the thread's counter, and `REPEAT_NEXT` the empty-check + bit, exactly as the evaluator reads its two registers: an iteration that + consumed nothing after the minimum fails. Leaving a repeat clears its bits, + so equal continuations have equal keys. +- A thread that reaches a thread already reached at this position is dropped: + everything it could do, the earlier and higher-priority one does first. +- A consuming instruction that accepts the character becomes a thread of the + next state, with the empty-check bits of its enclosing repeats set. + +This is the evaluator's order of exploration without its frames, so the first +thread to reach `MATCH` is the match the evaluator would return at this +position, if no thread before it matches later. + +### Leftmost-first + +An unanchored search adds a thread at instruction zero after all others at +every position, so earlier starts keep priority over later ones. When a thread +reaches `MATCH`, the position is recorded as a match end, every thread after it +is dropped and starts stop being added. Threads before it continue: if one of +them matches later, it is the match the evaluator would have found first, and +its end replaces the recorded one. The search ends when no thread remains or +the subject ends; the last recorded end is the end of the evaluator's match, +and its start is the leftmost start that has any match. + +`is_match` stops at the first recorded end. + +### The start + +The start comes from a reverse automaton over the same instructions. Its +thread at instruction `pc` and position `p` means: the evaluator at `pc` at `p` +can reach `MATCH` at the end already found. Moving left, it follows the +program's edges backwards and consumes characters backwards. Any path will do +here, not the evaluator's preferred one, and over the set of paths the +empty-iteration check changes nothing: removing an empty optional iteration +from an accepting path leaves an accepting path with the same ends. Counters +count iterations from the right, and leaving a repeat at its `REPEAT_INIT` +requires the count to lie between its minimum and maximum. Every position where +instruction zero is reached is a start; scanning left to the search's own start +finds the leftmost one. No match can begin left of the leftmost start with a +match, so that is the evaluator's start. + +A sticky or start-anchored search has one start, the requested one, and needs +no reverse pass. + +## Characters + +The automaton reads what the evaluator reads: code points under `u` or `v`, +code units otherwise, through the same cursor over the subject's original +storage. Characters are grouped into classes by the predicates the program +applies to them: one predicate per distinct consuming instruction, evaluated +with the evaluator's own rules (case equivalents, property tables, the `v` +complement), plus the line-terminator and word-character bits the program's +assertions read. Two characters with the same predicate results are +indistinguishable to the program, so transitions are kept per class. + +Classes are found lazily. ASCII characters keep their class in a 128-entry +table; other characters are kept in a hashed memo. A character not yet seen +has its predicates evaluated once, and the result is matched against the +classes so far or added as a new one. A search over wholly ASCII storage reads +bytes directly; others decode through the cursor. + +Positions are UTF-16 units throughout, and a Unicode search starts at the +code point holding the requested position, as the evaluator's does. + +## Cache + +The cache is host-owned `u32` storage passed to every call as `&mut [u32]`: +a header, the ASCII class table, the start-state slots, tables derived from the +program (each instruction's predicate and the empty-check bits its consumption +sets, each repeat's field, the reverse edges; in the header, the leading +claim's byte pairs and whether a non-ASCII character can begin a match), the +non-ASCII class memo, the +class signatures, a work area for one transition, a hash of states, and the +region states are allocated from. It holds offsets only, so the host may move +it between calls, and nothing in it refers to the subject. + +It belongs to one program. The host keeps one cache per program; each call +compares the program's length and the header words that most often tell +programs apart with the ones the cache was built for, and rebuilds it on a +mismatch. That comparison is a diagnostic, like the binding's header check, not +proof of identity: a cache handed a different program with the same length and +header can answer wrongly, which is the host's error. A debug build compares +the whole header. + +The host decides its size, which is the hard cap; nothing grows. +`dfa::minimum_words(program)` is the least storage the program can be searched +in, and `dfa::stats(cache)` reports the storage, the words in use, the peak, +the states, classes and clears, and whether the cache disabled itself. A larger +cache holds more states and a larger work area; the work area allows four +threads per instruction, capped by a sixty-fourth of the storage. + +When the state region fills, or the state hash passes half full, every state is +dropped and the search goes on from its current state, which is rebuilt first. +Widening the rows for a new class drops them the same way. A clear that came +after fewer than eight characters per state built since the previous one counts +as thrashing; after three such clears in a row the cache disables itself and +every later call declines until the host resets it (`dfa::reset`). A +transition that needs more threads than the work area holds, or a subject with +more classes than the signature table holds, declines that search only. + +## Answers + +- `dfa::is_match(program, input, start, cache, budget)`: `Tested::Match`, + `Tested::NoMatch` or `Tested::Declined(reason)`. +- `dfa::find(program, input, start, cache, budget)`: `Found::Match(span)` with + the span of group zero, `Found::NoMatch`, or `Found::Declined(reason)`. + +The reasons are `Ineligible`, `Storage { required_words }`, `Disabled` and +`Capacity`. A declined search has changed nothing but the cache and the budget, +and the host runs the evaluator. Captures always come from the evaluator: +`Search` started at the span's start finds the same match there first, and the +probe checks exactly that on every case. A no-match needs no evaluator at all. + +Each character costs one unit of the budget and each thread a transition visits +one more; an exhausted budget is `ExecError::WorkLimit`, never a no-match. The +automaton never pauses: its work is linear in the subject plus the states it +builds, so it takes no quantum. A start beyond the subject is a no-match, as +the evaluator's is; sticky and global semantics and `lastIndex` stay the +host's. + +### The steady path + +Once a program's states and classes are cached, a search over byte storage +runs without building anything: one table read for an ASCII character's class, +one for the transition, and one comparison that tells a live, known, unflagged +transition from everything else. Over wholly ASCII storage it starts anywhere; +over other WTF-8 storage it starts at the subject's start and decodes each +scalar once, taking runs of ASCII bytes the same way. A non-ASCII character's +class is read from the first slot of its memo probe. Positions in UTF-16 units +are counted only for a match end, from the last one counted, so a scan that +finds nothing never counts them. Anything not cached — and a high surrogate +under `u`, which may pair with a separately encoded low one — sends the search +to the general path from its start, before any state has changed. Over wholly +ASCII storage the reverse pass has a steady path of its own, under the same +rule. + +### The idle skip + +An unanchored search in its start state with no assertion context — no thread +alive, only the start to be added at the next position — can begin a match +only where the evaluator's candidate phase would start a trial. It skips there +with the evaluator's own scans (`src/executor/candidate.rs`), and only runs +transitions from the position the scan stops at: + +- a program with a leading claim (word 9) looks for the claim's byte pairs, + `first_in_pairs`, which stops at a small fraction of the positions the first + character alone admits; +- otherwise, the bytes word 7's first-character descriptor admits, by the same + word arithmetic `first_in_range` uses, four words to a branch. + +Over other WTF-8 storage the descriptor speaks only for ASCII, so the scan stops +at every non-ASCII character, as the evaluator's mixed scan does; a character +whose cached transition leaves the start state where it is is passed over +without leaving the skip. When the cache's build found that no consuming +instruction reachable from instruction zero without consuming accepts a +non-ASCII character (a character instruction, an unfolded or ASCII-folded one, +or a class whose ranges all lie below 128 — anything else counts as accepting +one), no non-ASCII character can begin a match, and the scan passes over them +with the ASCII bytes it rejects; the byte pairs are then exact over this storage +too, unless the leading run is folded, since a folded `k` matches U+212A under +`u`. The skip is set up only when at least 32 bytes remain. + +## For a host + +Perry, or any host, needs: + +- one cache per compiled program, allocated by the host to a size it chooses + (at least `dfa::minimum_words`), kept with the program and accounted with it; +- `dfa::is_match` for `test` and any operation that only needs to know whether + there is a match; +- `dfa::find` for the bounds, then `Search` from the span's start only when the + operation needs captures beyond group zero; +- the evaluator whenever the answer is `Declined`, exactly as before. + +The program format is the only change to existing APIs: format 15 programs +carry the eligibility bit, and programs of earlier formats are rejected as +before. Nothing else in the evaluator, the bindings or the resumable search +changed. + +## Verification + +- `tests/dfa.rs`: answers against the evaluator for literals, classes, case + folding, properties, `v` classes, assertions, counted, lazy and nullable + repeats, sticky and anchored searches and long subjects on the steady path, + every start position; 1,500 random eligible patterns against the evaluator; + eligibility derived both ways on validation; storage below the minimum; + clearing a full cache with exact answers; thrashing that disables the cache + until reset; a cache moved and poisoned between calls and rebuilt for another + program; an exhausted budget as an error. Long subjects cover the idle skip + over both storages: leading pairs, exact and folded (`ka`, `sk` and `ak` + under `iu` against U+017F and U+212A), first characters that may or may not + be non-ASCII, and matches after two-, three- and four-byte characters, whose + positions are counted late. +- Unit tests in `src/dfa.rs`: the steady path's decoder against every scalar + and surrogate from U+0080 to U+10FFFF; the late UTF-16 count against every + encoding width; and two-, three- and four-byte characters and a pair encoded + as two surrogates, each landing on a class the cache already holds, answered + by the steady path itself — it must answer without leaving to the general + path, and answer the class's transition. +- `examples/engine_probe.rs` with `PEREX_DFA=1` (roomy cache) or `small` (the + minimum plus 256 words, which clears and thrashes): every eligible case is + answered by `is_match` and `find` as well, with captures from the evaluator + started at the automaton's start, and any disagreement is a `dfa-mismatch` + outcome; where the evaluator runs out of work, the automaton's answer is + printed for Node to check. CI runs every engine harness three times: without + the automaton, with `1` and with `small`. +- `tools/check-dfa-fuzz.mjs PROBE OUTPUT_DIR --seed N --cases N`: random + eligible patterns, flags and starts over subjects with case pairs, folding + letters, line terminators, pairs and lone halves, against Node. The only + differences it sets aside are those of the reviewed `\B`-between-halves Node + disagreement in [reference disagreements](reference-disagreements.md); every + other difference fails. A case whose match the evaluator could not finish + within the probe's allowance is listed as `evaluator_work_limit`: the + automaton found the match, but its captures need the evaluator. +- Sabotage: trying a split's second arm first, a greedy repeat that prefers to + exit, folding dropped from characters or from classes, states allocated past + the storage, thrashing that never disables, empty iterations accepted, the + reverse pass ignoring a repeat's bounds, starts added after a match, `\n` as + the only line terminator, a backreference program marked eligible, the steady + skip passing over the top of its range, the WTF-8 skip passing over + non-ASCII characters, a misdecoded three-byte and a misdecoded four-byte + scalar on the steady path, a class reaching past ASCII counted as an ASCII + first character, folded leading pairs compared as bytes over WTF-8, and a + late position count that ignores four-byte sequences each turn the tests red. + The misdecoded three-byte scalar once stayed green: the wrong value missed + the class memo and the general path answered correctly. The steady-path unit + test now requires the steady path itself to answer. + +## Measurements + +Instructions per search, exact (callgrind on x86-64), with the warm-up — the +cache's construction and the first states — excluded: the difference between +1,000 + N and 1,000 searches (5 + N and 5 for 1 KB, 1 + N and 1 for 64 KB). +Each search is what a host pays per call: bind the counted subject, view both +through `BoundResources`, search from 0. The evaluator runs +`run_without_captures` (`test`) or `run` (`exec`); the automaton runs +`is_match` or `find` with one cache of 65,536 words kept across calls. The +binding alone costs 129 of each short-subject figure. Subjects joined by commas +are searched in turn. The evaluator's `test` figures at this release are +identical to 0.1.12's on every row. + +| Case | test 0.1.12 | `is_match` | exec 0.1.12 | `find` | +|---|---:|---:|---:|---:| +| `/^\p{DI}$/u`, `t,o,k,e,n,1,7, ` | 385 | 291 | 384 | 286 | +| the same, plus `─,│,é,中,😀` | 794 | 313 | 793 | 318 | +| `/^\p{DI}$/u` on `中` | 1,451 | 351 | 1,450 | 371 | +| `/^\p{DI}$/u` on U+00AD (match) | 1,553 | 368 | 1,648 | 438 | +| emoji pattern, `t,o,k,e,n,1,7, ` | 940 | 292 | 939 | 297 | +| the same, plus `─,│,é,中,😀` | 2,195 | 325 | 2,201 | 374 | +| emoji pattern on `t` | 400 | 292 | 399 | 297 | +| emoji pattern on `1` | 2,561 | 292 | 2,560 | 297 | +| emoji pattern on `中` | 1,629 | 369 | 1,628 | 387 | +| emoji pattern on `😀` (match) | 5,534 | 424 | 5,629 | 948 | +| `/ab\|cd\|ef\|gh/` on `xxgh` | 2,020 | 324 | 2,115 | 448 | +| `/(foo\|bar\|baz)=(\d+)/` on `a=1&baz=42` (match) | 3,658 | 390 | 3,811 | 637 | +| `/a/` on `t` | 400 | 292 | 399 | 297 | +| `/a/` on `a` (match) | 1,058 | 288 | 1,153 | 373 | +| `/qq_[0-9]+/` on `record_12345` | 452 | 424 | 451 | 429 | +| `/\w+/g` on `foo bar` (match) | 2,083 | 294 | 2,178 | 489 | +| `/^[a-z]+_[0-9]+$/` on `record_12345` (match) | 2,879 | 425 | 2,974 | 438 | +| `/\s+$/` on `foo bar ` (match) | 3,913 | 401 | 4,008 | 556 | +| `/\d{3}-\d{4}/`, 1 KB ASCII, no match | 6,188 | 3,186 | 6,187 | 3,184 | +| the same, 1 KB WTF-8 | 6,175 | 4,179 | 6,174 | 4,183 | +| the same, 64 KB ASCII | 359,130 | 176,939 | 359,129 | 176,937 | +| the same, 64 KB WTF-8 | 359,437 | 236,940 | 359,436 | 236,944 | +| `/zq\|qz\|xj/`, 1 KB ASCII, no match | 4,567 | 4,136 | 4,566 | 4,134 | +| the same, 64 KB ASCII | 227,145 | 215,816 | 227,144 | 215,814 | +| the same, 64 KB WTF-8 | 14,221,128 | 215,898 | 14,221,127 | 215,902 | + +No row costs more than the evaluator's. Short searches are dominated by the +evaluator's per-search setup and backtracking, which the automaton does not +have; of the automaton's own figure, about 160 instructions above the binding +are its fixed cost: the header and cache checks, the start state, the steady +loop's setup and the budget. Long scans skip with the evaluator's own scans +(see the idle skip), so they cost what the scan costs plus the transitions from +the positions it stops at; over WTF-8 storage, where the evaluator's candidate +scan stops at every non-ASCII character, the automaton passes over the ones +that cannot begin a match. `find` adds the reverse pass to the start, which +over ASCII storage takes its own steady path. Where the operation needs +captures beyond group zero, the evaluator still runs from the span's start. + +Cache storage: the minimum is 2,723–2,815 words (about 11 KB) for these +patterns and 12,275 words (48 KB) for the emoji pattern; with 65,536 words of +storage, the most in use was 10,635–11,576 words for the small patterns and +33,220 for the emoji pattern. Most of it is the work area and tables sized +from the storage and the program; the states themselves were one to thirteen. + diff --git a/docs/engine.md b/docs/engine.md index 362b30e..cae9fdd 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -20,7 +20,7 @@ Unicode-mode [character properties](properties.md), including general categories `compile` receives a borrowed pattern, caller-owned `Node`/`Range` scratch, final `u32` storage and a work budget. Nodes contain indices only. The emitter walks the topologically ordered arena iteratively: a long concatenation does not recurse on the native stack, and a counted repetition is not expanded into one program copy per count. Group parsing has an explicit nesting limit of 128; sizes/counters use checked arithmetic and u32 limits. These remain implementation limits, not claims that deeper/larger valid expressions are invalid JavaScript. -The output is one immutable relocatable buffer: an eleven-word header, three words per instruction, class-table pairs, eight-word repetition records, a filter section for the [properties of strings](sets.md), and an optional [name section](names.md). A class-table pair is a literal interval or shared Unicode property reference/complement. Capture indices are u32, with tests above 254 groups. The native-u32 format needs four-byte alignment and is versioned but unstable; version 14 adds the first-character branch instruction and the stored start-anchored claim described in [branches](branch.md), retaining version 13's properties of strings — an instruction naming a shared set of sequences and the filter an operator left on it — and version 10's final-character descriptor for strict end-anchored patterns, version 9's [sorted literal classes](classes.md), version 8's candidate-start descriptor and version 7's consuming-atom repeat records and lexical flag instructions. Older versions are rejected; this is not cross-endian serialization. `Program::from_words` validates all opcodes, operand ranges, property IDs/modes and branch/table bounds, packed identifier names and capture-index lists under a work budget. It also validates the exact five-instruction shape and capture exclusion for optimized atom repeats. It does not prove arbitrary hand-authored bytecode has compiler-generated control structure or that a candidate/admission hint is a semantic consequence of its instructions; execution can reject invalid control/capture states and is always work-limited. +The output is one immutable relocatable buffer: an eleven-word header, three words per instruction, class-table pairs, eight-word repetition records, a filter section for the [properties of strings](sets.md), and an optional [name section](names.md). A class-table pair is a literal interval or shared Unicode property reference/complement. Capture indices are u32, with tests above 254 groups. The native-u32 format needs four-byte alignment and is versioned but unstable; version 15 adds the validated [automaton eligibility](dfa.md) bit to word 7, retaining version 14's first-character branch instruction and stored start-anchored claim described in [branches](branch.md), and version 13's properties of strings — an instruction naming a shared set of sequences and the filter an operator left on it — and version 10's final-character descriptor for strict end-anchored patterns, version 9's [sorted literal classes](classes.md), version 8's candidate-start descriptor and version 7's consuming-atom repeat records and lexical flag instructions. Older versions are rejected; this is not cross-endian serialization. `Program::from_words` validates all opcodes, operand ranges, property IDs/modes and branch/table bounds, packed identifier names and capture-index lists under a work budget. It also validates the exact five-instruction shape and capture exclusion for optimized atom repeats. It does not prove arbitrary hand-authored bytecode has compiler-generated control structure or that a candidate/admission hint is a semantic consequence of its instructions; execution can reject invalid control/capture states and is always work-limited. [Required-text admission](admission.md) can reject a long search when a compiler-proved literal/class condition is absent from the original subject. It uses existing instructions and header bits, without increasing program size. Presence still executes the same VM for complete answers. diff --git a/docs/leading.md b/docs/leading.md index b7a2e0f..66bff38 100644 --- a/docs/leading.md +++ b/docs/leading.md @@ -23,7 +23,7 @@ because word 7 already carries that claim. ASCII bytes cannot occur inside a multibyte UTF-8/WTF-8 encoding, so a run of ASCII characters is also a claim about original bytes. -Program format 14 has eleven header words. Word 9's low byte holds the run's +Program format 15 has eleven header words. Word 9's low byte holds the run's character count, or zero when disabled; its top bit is the separate forward-admission claim described in [admission](admission.md). The word costs four used program bytes, and the two claims share it rather than adding another. diff --git a/examples/engine_probe.rs b/examples/engine_probe.rs index 3b3e912..48125e0 100644 --- a/examples/engine_probe.rs +++ b/examples/engine_probe.rs @@ -3,6 +3,7 @@ use perex::{ Budget, compiler::{CompileError, Node, Range, compile}, + dfa, executor::{Frame, Scratch, Undo, find}, input::Input, span::Span, @@ -95,6 +96,17 @@ fn main() -> Result<(), Box> { if args.next().is_some() { return Err("extra argument".into()); } + // `PEREX_DFA` answers every eligible case with the lazy automaton as well + // and reports any disagreement as an outcome of its own, so every harness + // that runs this probe compares the automaton too: `1` with a roomy cache, + // `small` with the least storage each program accepts plus a little, which + // clears and thrashes, or a number of words. + let automaton = std::env::var("PEREX_DFA") + .ok() + .filter(|mode| !mode.is_empty()); + let mut cache: Vec = Vec::new(); + let mut cached_program: Vec = Vec::new(); + let (mut eligible, mut answered) = (0usize, 0usize); let mut out = io::BufWriter::new(io::stdout().lock()); for line in io::stdin().lock().lines() { let line = line?; @@ -168,6 +180,121 @@ fn main() -> Result<(), Box> { } else { find(program, input, start, scratch, &mut captures, &mut budget) }; + // Where the evaluator ran out of work, the automaton's answer stands + // in for it: no match, or the evaluator's own captures from the start + // the automaton found. Node then checks that answer. + let found = match found { + Err(perex::executor::ExecError::WorkLimit) + if automaton.is_some() && dfa::eligible(program) => + { + let mut cache = vec![0; dfa::minimum_words(program).unwrap().max(1 << 16)]; + match dfa::find(program, input, start, &mut cache, &mut Budget::new(work)) { + Ok(dfa::Found::NoMatch) => Ok(false), + Ok(dfa::Found::Match(span)) => { + let mut registers = vec![0; program.register_count()]; + let mut frames = vec![Frame::default(); 16_384]; + let mut undo = vec![Undo::default(); 131_072]; + let rerun = find( + program, + input, + span.start(), + Scratch { + registers: &mut registers, + frames: &mut frames, + undo: &mut undo, + }, + &mut captures, + &mut Budget::new(work), + ); + match rerun { + Ok(true) if captures[0] == Some(span) => Ok(true), + Ok(_) => { + writeln!( + out, + "{{\"id\":{id:?},\"outcome\":\"dfa-mismatch\",\"rerun\":\"{rerun:?}\"}}" + )?; + continue; + } + Err(error) => Err(error), + } + } + other => { + if std::env::var_os("PEREX_DFA_DEBUG").is_some() { + eprintln!("{id}: evaluator out of work, automaton {other:?}"); + } + found + } + } + } + found => found, + }; + if let Some(size) = automaton.as_deref() + && let Some(minimum) = dfa::minimum_words(program) + && let Ok(vm) = found + { + let words = match size { + "small" => minimum + 256, + "1" => minimum.max(1 << 16), + number => number.parse::()?.max(minimum), + }; + // One cache per program: a different program starts a new one. + if cached_program != program.words() || cache.len() != words { + cache = vec![0; words]; + cached_program = program.words().to_vec(); + } + let mut budget = Budget::new(work); + let tested = dfa::is_match(program, input, start, &mut cache, &mut budget); + let mut budget = Budget::new(work); + let bounds = dfa::find(program, input, start, &mut cache, &mut budget); + eligible += 1; + answered += usize::from(!matches!(bounds, Ok(dfa::Found::Declined(_)))); + // A declined call is not an answer: the cache may also disable + // itself between the two calls. Every answer must be the + // evaluator's. + let tested_agrees = match tested { + Ok(dfa::Tested::Declined(_)) => true, + Ok(dfa::Tested::Match) => vm, + Ok(dfa::Tested::NoMatch) => !vm, + // Exhausting the allowance is not an answer either. + Err(perex::executor::ExecError::WorkLimit) => true, + Err(_) => false, + }; + let bounds_agree = match bounds { + Ok(dfa::Found::Declined(_)) => true, + Ok(dfa::Found::NoMatch) => !vm, + Ok(dfa::Found::Match(span)) => { + // Captures come from the evaluator started at the + // automaton's start: they must be the same match. + let mut again = vec![None; program.capture_count()]; + let mut registers = vec![0; program.register_count()]; + let mut frames = vec![Frame::default(); 16_384]; + let mut undo = vec![Undo::default(); 131_072]; + let rerun = find( + program, + input, + span.start(), + Scratch { + registers: &mut registers, + frames: &mut frames, + undo: &mut undo, + }, + &mut again, + &mut Budget::new(work), + ); + vm && captures[0] == Some(span) && rerun == Ok(true) && again == captures + } + Err(perex::executor::ExecError::WorkLimit) => true, + Err(_) => false, + }; + let agreed = tested_agrees && bounds_agree; + if !agreed { + writeln!( + out, + "{{\"id\":{id:?},\"outcome\":\"dfa-mismatch\",\"vm\":{vm},\"test\":\"{tested:?}\",\"find\":\"{bounds:?}\"}}" + )?; + continue; + } + } match found { Ok(false) => writeln!(out, "{{\"id\":{id:?},\"outcome\":\"no-match\"}}")?, Err(error) => writeln!( @@ -210,5 +337,8 @@ fn main() -> Result<(), Box> { } } } + if automaton.is_some() { + eprintln!("dfa: eligible {eligible} answered {answered}"); + } Ok(()) } diff --git a/src/compiler/prepared.rs b/src/compiler/prepared.rs index ed74e6f..d145a45 100644 --- a/src/compiler/prepared.rs +++ b/src/compiler/prepared.rs @@ -275,6 +275,11 @@ impl Prepared<'_> { crate::program::ANCHORED } else { 0 + } + | if crate::program::derive_dfa(Program { words: output }) { + crate::program::DFA + } else { + 0 }; output[8] = parser.end_candidate_descriptor(root); // Derived from the emitted instructions with the same function the diff --git a/src/dfa.rs b/src/dfa.rs new file mode 100644 index 0000000..9f765d3 --- /dev/null +++ b/src/dfa.rs @@ -0,0 +1,2312 @@ +//! A lazy deterministic automaton over host-owned storage: whether a program +//! matches, and the bounds of its match, without backtracking. States are +//! built from the program's instructions when a search first reaches them and +//! kept in a cache the host allocates, sizes and may move. See `docs/dfa.md`. +//! +//! The backtracking evaluator remains the definition of every answer and the +//! only source of captures. Programs the compiler did not mark eligible, and +//! searches the cache cannot hold, are declined and left to it. +use crate::{ + Budget, casefold, + executor::{ + ExecError, + candidate::{Prefix, first_in_pairs, prefix, scan_range}, + }, + input::{Cursor, Input}, + program::*, + properties, + span::Span, +}; + +/// Why a search was left to the evaluator. A declined search changed nothing +/// but the cache and the budget. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Decline { + /// The program uses something an automaton cannot decide exactly. + Ineligible, + /// The cache storage is smaller than this program's minimum. + Storage { required_words: usize }, + /// Clearing repeatedly showed this program's states do not stay cached. + /// Every call declines until the host resets the cache. + Disabled, + /// One transition needed more threads, or the subject more character + /// classes, than this cache holds. + Capacity, +} + +/// The answer of [`is_match`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tested { + Match, + NoMatch, + Declined(Decline), +} + +/// The answer of [`find`]: the span of capture group zero. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Found { + Match(Span), + NoMatch, + Declined(Decline), +} + +/// What a cache holds, for the host's memory accounting. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct CacheStats { + /// Storage the cache's layout covers: the host's allocation. + pub words: usize, + /// Words in use now: tables, signatures and live states. + pub used_words: usize, + /// The most words ever in use since the cache was built. + pub peak_words: usize, + pub states: usize, + pub classes: usize, + pub clears: usize, + pub disabled: bool, +} + +const MAGIC: u32 = 0x5058_4446; +const LAYOUT: u32 = 1; +const HEADER_WORDS: usize = 64; + +// Header words. +const H_MAGIC: usize = 0; +const H_LAYOUT: usize = 1; +const H_LEN: usize = 2; +/// Program words 2..=10, compared on every call. +const H_PROGRAM: usize = 3; +const H_CAP: usize = 12; +const H_STATUS: usize = 13; +const H_COLUMNS: usize = 14; +const H_CLASSES: usize = 15; +const H_PREDICATES: usize = 16; +const H_SIGW: usize = 17; +const H_NEED: usize = 18; +const H_BUMP: usize = 19; +const H_STATES: usize = 20; +const H_CLEARS: usize = 21; +const H_BAD: usize = 22; +const H_UNITS: usize = 23; +const H_BUILT: usize = 24; +const H_PEAK: usize = 25; +const H_GEN: usize = 26; +const H_MEMO_USED: usize = 27; +const H_THREADS: usize = 28; +const H_PRED: usize = 29; +const H_FLAGS: usize = 30; +const H_REP: usize = 31; +const H_RPS: usize = 32; +const H_RPL: usize = 33; +const H_REPS: usize = 34; +const H_MEMO: usize = 35; +const H_SIGTMP: usize = 36; +const H_TABLE: usize = 37; +const H_TABLE_SLOTS: usize = 38; +const H_STACK: usize = 39; +const H_OUT: usize = 40; +const H_SAVED: usize = 41; +const H_SIG: usize = 42; +const H_SIGCAP: usize = 43; +const H_HASH: usize = 44; +const H_HASH_SLOTS: usize = 45; +const H_REGION: usize = 46; +/// The byte pairs the program's leading claim admits, as the evaluator's +/// candidate scan reads them: their count, then the first bytes and the second +/// bytes, four to a word. +const H_PAIRS: usize = 47; +/// Nonzero when no consuming instruction a search can reach from instruction +/// zero without consuming accepts a non-ASCII character. +const H_ASCII_START: usize = 52; + +const ASCII: usize = HEADER_WORDS; +const SLOTS: usize = ASCII + 128; +const FRONT: usize = SLOTS + 48; + +/// A transition not yet computed. +const UNKNOWN: u32 = 0; +/// The column of a character whose class is not known yet. No transition is +/// ever stored in it, so it reads as `UNKNOWN` in every state and a lookup +/// needs no separate test for an unclassified character. Classes start at 2; +/// column 0 is the subject's edge. +const UNCLASSIFIED: u32 = 1; +/// No thread remains and no start will be added. +const DEAD: u32 = 1; +/// Forward: the position before this character ends a match. Reverse: it +/// begins one. +const FLAG: u32 = 1 << 31; + +/// What a state knows about the character on its consumed side, and a +/// transition about the other. `CX_EDGE` is the subject's start or end. +const CX_EDGE: u32 = 1; +const CX_LT: u32 = 2; +const CX_WORD: u32 = 4; +const CX_WORD_UI: u32 = 8; +/// Signature bit of the first predicate; bits 1..=3 are `CX_LT`, `CX_WORD` and +/// `CX_WORD_UI` of the character itself. +const PRED_BASE: usize = 4; + +const S_REVERSE: u32 = 1; +const S_INJECT: u32 = 2; + +const MODE_UNANCHORED: usize = 0; +const MODE_ANCHORED: usize = 1; +const MODE_REVERSE: usize = 2; + +const MEMO_SLOTS: usize = 512; +/// Storage at or above this many words is declined: a transition holds a +/// state's offset beside the match flag in bit 31. +const MAX_WORDS: usize = 1 << 30; +/// Subject bytes left below which the steady path does not set up the skip +/// to a byte a match can begin with. +const SKIP_MIN: usize = 32; +const FIRST_COLUMNS: usize = 8; +const MAX_CLASSES: usize = 1022; +/// A clear after fewer characters than this per state built is thrashing. +const UNITS_PER_STATE: u32 = 8; +/// Consecutive thrashing clears that disable the cache. +const THRASH_LIMIT: u32 = 3; +/// Visited-table key bit marking an entry of the next state's thread list. +const OUT_KEY: u32 = 1 << 31; +const GEN_SHIFT: u32 = 24; +const PC_MASK: u32 = (1 << GEN_SHIFT) - 1; + +/// Whether the compiler marked this program for the automaton. +pub fn eligible(program: Program<'_>) -> bool { + program.words[7] & DFA != 0 +} + +/// The least cache storage, in words, that `program` can be searched in, or +/// `None` when it is not eligible. More storage holds more states. +pub fn minimum_words(program: Program<'_>) -> Option { + if !eligible(program) { + return None; + } + let (edges, consuming) = census(program); + let n = program.instructions(); + Some(plan(n, program.words[6] as usize, consuming, edges, 0, true).required) +} + +/// The program's epsilon edges and consuming instructions. +fn census(program: Program<'_>) -> (usize, usize) { + let mut edges = 0; + let mut count = 0; + for pc in 0..program.instructions() { + edges += successors(program, pc).0; + count += usize::from(consuming(program.instruction(pc)[0])); + } + (edges, count) +} + +/// Forget everything the cache holds. The next search rebuilds it. +pub fn reset(cache: &mut [u32]) { + if let Some(first) = cache.first_mut() { + *first = 0; + } +} + +/// What `cache` holds, or `None` when it holds no built cache. +pub fn stats(cache: &[u32]) -> Option { + if cache.len() < HEADER_WORDS || cache[H_MAGIC] != MAGIC || cache[H_LAYOUT] != LAYOUT { + return None; + } + Some(CacheStats { + words: cache[H_CAP] as usize, + used_words: cache[H_BUMP] as usize, + peak_words: cache[H_PEAK] as usize, + states: cache[H_STATES] as usize, + classes: cache[H_CLASSES] as usize, + clears: cache[H_CLEARS] as usize, + disabled: cache[H_STATUS] != 0, + }) +} + +/// Whether `program` matches `input` at or after `start_utf16`, answered by +/// the automaton in `cache`. The cache must belong to this program: the host +/// keeps one per program. A cache built for another program of the same +/// length and header is the host's error and may give wrong answers; one with +/// a different length or header is rebuilt. +pub fn is_match( + program: Program<'_>, + input: Input<'_>, + start_utf16: usize, + cache: &mut [u32], + budget: &mut Budget, +) -> Result { + Ok( + match search(program, input, start_utf16, cache, budget, false)? { + Outcome::Declined(why) => Tested::Declined(why), + Outcome::NoMatch => Tested::NoMatch, + Outcome::Matched(_) => Tested::Match, + }, + ) +} + +/// The span of the match the evaluator would return for `program` on `input` +/// at or after `start_utf16` — group zero, leftmost first — answered by the +/// automaton in `cache`. Captures come from the evaluator, started at the +/// span's start. The cache contract is [`is_match`]'s. +pub fn find( + program: Program<'_>, + input: Input<'_>, + start_utf16: usize, + cache: &mut [u32], + budget: &mut Budget, +) -> Result { + Ok( + match search(program, input, start_utf16, cache, budget, true)? { + Outcome::Declined(why) => Found::Declined(why), + Outcome::NoMatch => Found::NoMatch, + Outcome::Matched(span) => Found::Match(span.ok_or(ExecError::InvalidProgram)?), + }, + ) +} + +enum Outcome { + Matched(Option), + NoMatch, + Declined(Decline), +} + +/// Why a search stopped before deciding. +enum Stop { + Declined(Decline), + Error(ExecError), +} +impl From for Stop { + fn from(error: ExecError) -> Self { + Stop::Error(error) + } +} + +#[inline(always)] +fn search( + program: Program<'_>, + input: Input<'_>, + start_utf16: usize, + cache: &mut [u32], + budget: &mut Budget, + bounds: bool, +) -> Result { + // A validated program holds its whole header; reading it as one array + // checks that once. + let Some(words) = program.words.first_chunk::
() else { + return general(program, input, start_utf16, cache, budget, bounds); + }; + let anchored = words[2] & Y != 0 || words[7] & ANCHORED != 0; + if words[7] & DFA != 0 + && built_for(words, program.words.len(), cache) + && cache[H_STATUS] == 0 + && start_utf16 <= input.len_utf16() + && let Some(end) = steady( + program, + input, + start_utf16, + cache, + budget, + !bounds, + anchored, + ) + { + return match end { + None => Ok(Outcome::NoMatch), + Some(_) if !bounds => Ok(Outcome::Matched(None)), + Some(end) if anchored => Ok(Outcome::Matched(Span::new(start_utf16, end))), + // The steady path starts where it was asked to: ASCII storage + // needs no Unicode adjustment, and other storage starts at zero. + Some(end) => match reverse_steady(input, start_utf16, end, cache, budget) { + Some(begin) => Ok(Outcome::Matched(Span::new(begin, end))), + None => reverse_only(program, input, start_utf16, end, cache, budget), + }, + }; + } + general(program, input, start_utf16, cache, budget, bounds) +} + +/// The reverse pass over wholly ASCII storage when every transition it takes +/// is cached: the leftmost start at or after `lower` of the match ending at +/// `end`. `None` as soon as anything is not, before anything changes. +#[inline(always)] +fn reverse_steady( + input: Input<'_>, + lower: usize, + end: usize, + m: &mut [u32], + budget: &mut Budget, +) -> Option { + let bytes = input.ascii_bytes()?; + let need = m[H_NEED]; + let ctx = match bytes.get(end) { + _ if need == 0 => 0, + None => CX_EDGE & need, + Some(&byte) => context(u32::from(byte)) & need, + }; + let mut s = m[SLOTS + MODE_REVERSE * 16 + ctx as usize] as usize; + if s == 0 { + return None; + } + let table: &[u32; 128] = m[ASCII..ASCII + 128].try_into().ok()?; + let mut found = None; + let mut at = end; + loop { + let column = match at.checked_sub(1) { + Some(before) => table[usize::from(*bytes.get(before)? & 127)] as usize, + None => 0, + }; + let entry = m[s + column]; + if entry == UNKNOWN { + return None; + } + if entry & FLAG != 0 { + found = Some(at); + } + let next = (entry & !FLAG) as usize; + if at == 0 || at <= lower || next == DEAD as usize { + break; + } + s = next; + at -= 1; + } + let found = found?; + budget.charge(end - at + 1).ok()?; + m[H_UNITS] = m[H_UNITS].saturating_add((end - at + 1).min(u32::MAX as usize) as u32); + Some(found) +} + +/// The reverse pass alone, for a match end the steady path found. +#[inline(never)] +fn reverse_only( + program: Program<'_>, + input: Input<'_>, + start: usize, + end: usize, + cache: &mut [u32], + budget: &mut Budget, +) -> Result { + let mut dfa = Dfa { + program, + m: cache, + budget: *budget, + progress: 0, + flushed: 0, + unicode: program.unicode(), + }; + let result = dfa.reverse(input, end, start); + dfa.flush_units(); + *budget = dfa.budget; + match result { + Ok(begin) => Ok(Outcome::Matched(Span::new(begin, end))), + Err(Stop::Declined(why)) => Ok(Outcome::Declined(why)), + Err(Stop::Error(error)) => Err(error), + } +} + +/// Everything [`search`]'s steady path leaves: building the cache, states +/// and classes not yet cached, other storage, and the reverse pass. +#[inline(never)] +fn general( + program: Program<'_>, + input: Input<'_>, + start_utf16: usize, + cache: &mut [u32], + budget: &mut Budget, + bounds: bool, +) -> Result { + if !eligible(program) { + return Ok(Outcome::Declined(Decline::Ineligible)); + } + let built = program + .words + .first_chunk::
() + .is_some_and(|words| built_for(words, program.words.len(), cache)); + if !built && let Err(why) = build(program, cache) { + return Ok(Outcome::Declined(why)); + } + if cache[H_STATUS] != 0 { + return Ok(Outcome::Declined(Decline::Disabled)); + } + if start_utf16 > input.len_utf16() { + return Ok(Outcome::NoMatch); + } + let mut dfa = Dfa { + program, + m: cache, + budget: *budget, + progress: 0, + flushed: 0, + unicode: program.unicode(), + }; + let result = dfa.run(input, start_utf16, bounds); + dfa.flush_units(); + *budget = dfa.budget; + match result { + Ok(outcome) => Ok(outcome), + Err(Stop::Declined(why)) => Ok(Outcome::Declined(why)), + Err(Stop::Error(error)) => Err(error), + } +} + +/// The forward pass over byte storage when everything it meets is already +/// cached: the start state, every character's class (in the ASCII table or +/// the first slot of its memo probe) and every transition. `None` as soon as +/// anything is not, before any state changes; the general path then runs the +/// search from its start. +/// +/// Wholly ASCII storage is searched from any start: it holds no surrogate, so +/// a Unicode start needs no adjustment. Other WTF-8 storage is searched from +/// the subject's start only, decoding each scalar once; a high surrogate under +/// `u`, which may pair with a separately encoded low one, is left to the +/// general path. +/// +/// With `first` it stops at the first match end and reports that there is +/// one; over WTF-8 storage the position it carries is then not counted. +#[inline(always)] +fn steady( + program: Program<'_>, + input: Input<'_>, + start: usize, + m: &mut [u32], + budget: &mut Budget, + first: bool, + anchored: bool, +) -> Option> { + let Some(bytes) = input.ascii_bytes() else { + if start != 0 { + return None; + } + return steady_wtf8(program, input.original_bytes()?, m, budget, first, anchored); + }; + let need = m[H_NEED]; + let ctx = if need == 0 { + 0 + } else if start == 0 { + CX_EDGE & need + } else { + return None; + }; + let mode = if anchored { + MODE_ANCHORED + } else { + MODE_UNANCHORED + }; + let s = m[SLOTS + mode * 16 + ctx as usize] as usize; + if s == 0 || budget.remaining() < bytes.len() { + return None; + } + let (last, end) = if bytes.len() - start >= SKIP_MIN + && !anchored + && need == 0 + && program.first_descriptor() != 0 + { + ascii_skipping(program, bytes, start, s, m, first)? + } else { + ascii_steady::(bytes, start, s, m, first, &NO_SKIP)? + }; + budget.charge(end - start).ok()?; + m[H_UNITS] = m[H_UNITS].saturating_add((end - start).min(u32::MAX as usize) as u32); + Some(last) +} + +/// Where the steady path may skip ahead: from state `idle`, to the next +/// position the scans below admit. +struct Skip { + idle: usize, + lo: u8, + hi: u8, + pairs: Prefix, +} + +const NO_SKIP: Skip = Skip { + idle: usize::MAX, + lo: 0, + hi: 0, + pairs: Prefix { + first: [0; LEADING_BRANCHES], + second: [0; LEADING_BRANCHES], + members: 0, + }, +}; + +/// An unanchored search with no thread alive and no context to carry can only +/// begin a match where the evaluator's candidate scan would start a trial, so +/// from the start state `idle` it skips to the next such position with the +/// same scans: the leading claim's byte pairs, or the bytes word 7's +/// first-character descriptor admits. A descriptor of one admits no ASCII +/// byte. The pairs compare bytes, which over other storage than ASCII is exact +/// only when no non-ASCII character can begin a match and the run is not +/// folded: a folded `k` matches U+212A under `u`. +fn skip_for(program: Program<'_>, m: &[u32], idle: usize, ascii: bool) -> Skip { + let descriptor = program.first_descriptor(); + let (lo, hi) = match descriptor { + 1 => (128, 127), + _ => ((descriptor >> 8) as u8, (descriptor >> 16) as u8), + }; + let members = m[H_PAIRS] as usize; + let pairs = if members != 0 && (ascii || (m[H_ASCII_START] != 0 && !program.leading_fold())) { + let byte = |k: usize| m[H_PAIRS + 1 + k / 4].to_le_bytes()[k % 4]; + Prefix { + first: core::array::from_fn(byte), + second: core::array::from_fn(|k| byte(k + LEADING_BRANCHES)), + members: members.min(LEADING_BRANCHES), + } + } else { + NO_SKIP.pairs + }; + Skip { + idle, + lo, + hi, + pairs, + } +} + +/// The next position at or after `i` the skip admits, or the subject's end. +#[inline(always)] +fn skip_ascii(bytes: &[u8], i: usize, skip: &Skip) -> usize { + let rest = &bytes[i..]; + let found = if skip.pairs.members != 0 { + first_in_pairs( + rest, + rest.len(), + &skip.pairs.first[..skip.pairs.members], + &skip.pairs.second[..skip.pairs.members], + ) + .0 + } else { + scan_range::(rest, skip.lo, skip.hi) + }; + i + found.unwrap_or(rest.len()) +} + +/// The steady path over a long ASCII subject, skipping from the start state. +#[inline(never)] +fn ascii_skipping( + program: Program<'_>, + bytes: &[u8], + start: usize, + s: usize, + m: &[u32], + first: bool, +) -> Option<(Option, usize)> { + let skip = skip_for(program, m, s, true); + ascii_steady::(bytes, start, s, m, first, &skip) +} + +/// The steady loop over wholly ASCII storage, where a byte is a character and +/// its index its position: the last match end, and where the search stopped. +#[inline(always)] +fn ascii_steady( + bytes: &[u8], + start: usize, + mut s: usize, + m: &[u32], + first: bool, + skip: &Skip, +) -> Option<(Option, usize)> { + let table: &[u32; 128] = m[ASCII..ASCII + 128].try_into().ok()?; + let mut last = None; + let mut i = start; + loop { + if SKIP && s == skip.idle && i < bytes.len() { + i = skip_ascii::(bytes, i, skip); + } + let Some(&byte) = bytes.get(i) else { + let entry = m[s]; + if entry == UNKNOWN { + return None; + } + if entry & FLAG != 0 { + last = Some(i); + } + break; + }; + let entry = m[s + table[usize::from(byte & 127)] as usize]; + // Live, known and unflagged: the steady state, one comparison. + if entry as i32 > DEAD as i32 { + s = entry as usize; + i += 1; + continue; + } + if entry == UNKNOWN { + return None; + } + if entry & FLAG != 0 { + last = Some(i); + if first { + break; + } + } + let next = (entry & !FLAG) as usize; + if next == DEAD as usize { + break; + } + s = next; + i += 1; + } + Some((last, i)) +} + +/// The steady path over WTF-8 storage that is not wholly ASCII, from the +/// subject's start, decoding each scalar once. +#[inline(always)] +fn steady_wtf8( + program: Program<'_>, + bytes: &[u8], + m: &mut [u32], + budget: &mut Budget, + first: bool, + anchored: bool, +) -> Option> { + let need = m[H_NEED]; + let mode = if anchored { + MODE_ANCHORED + } else { + MODE_UNANCHORED + }; + let s = m[SLOTS + mode * 16 + (CX_EDGE & need) as usize] as usize; + if s == 0 || budget.remaining() < bytes.len() { + return None; + } + let (last, end) = + if bytes.len() >= SKIP_MIN && !anchored && need == 0 && program.first_descriptor() != 0 { + wtf8_skipping(program, bytes, s, m, first)? + } else { + wtf8_steady::(program.unicode(), bytes, s, m, first, &NO_SKIP)? + }; + budget.charge(end).ok()?; + m[H_UNITS] = m[H_UNITS].saturating_add(end.min(u32::MAX as usize) as u32); + Some(last) +} + +/// The steady path over a long WTF-8 subject, skipping from the start state. +#[inline(never)] +fn wtf8_skipping( + program: Program<'_>, + bytes: &[u8], + s: usize, + m: &[u32], + first: bool, +) -> Option<(Option, usize)> { + let skip = skip_for(program, m, s, false); + wtf8_steady::(program.unicode(), bytes, s, m, first, &skip) +} + +/// The steady loop over WTF-8 storage from the subject's start: the last +/// match end, and where the search stopped, in bytes. +#[inline(always)] +fn wtf8_steady( + unicode: bool, + bytes: &[u8], + mut s: usize, + m: &[u32], + first: bool, + skip: &Skip, +) -> Option<(Option, usize)> { + let table: &[u32; 128] = m[ASCII..ASCII + 128].try_into().ok()?; + let idle = if SKIP { skip.idle } else { usize::MAX }; + // Without a non-ASCII first character, every non-ASCII character keeps + // the idle state where it is, so the skip passes over them with the + // ASCII bytes the descriptor or the pairs reject. + let wide = SKIP && m[H_ASCII_START] != 0; + let memo = m[H_MEMO] as usize; + let mut last = None; + // `i` indexes bytes. A position in UTF-16 units is needed only for a match + // end, so it is counted then, from the last one counted. + let mut i = 0; + // A search for the first match end (`first`) reports only that there is + // one, so it counts nothing. + let mut counted = (0, 0); + let mut position = |i: usize| { + if !first { + counted = (i, counted.1 + utf16_units(&bytes[counted.0..i])); + } + counted.1 + }; + // The low half of a pair a search without `u` reads as two characters. + let mut pending = 0; + loop { + if pending == 0 { + while s == idle && i < bytes.len() { + if wide { + i = skip_ascii::(bytes, i, skip); + break; + } + // The descriptor speaks only for ASCII: the scan stops at + // every non-ASCII lead byte, as the evaluator's mixed scan + // does. A character whose cached transition keeps the idle + // state where it is cannot begin a match either, so it is + // passed over here; anything else is left to the loop below. + i += scan_range::(&bytes[i..], skip.lo, skip.hi) + .unwrap_or(bytes.len() - i); + let Some(&lead) = bytes.get(i) else { break }; + if lead < 0x80 { + break; + } + let (point, width) = decode(bytes, i); + if (0xd800..0xe000).contains(&point) || (point > 0xffff && !unicode) { + break; + } + match memo_column(m, memo, point) { + Some(column) if m[s + column] == s as u32 => i += width, + _ => break, + } + } + // A run of ASCII bytes whose transitions are known, live and + // unflagged. + while let Some(&byte) = bytes.get(i) + && byte < 0x80 + && s != idle + { + let entry = m[s + table[usize::from(byte)] as usize]; + if entry as i32 <= DEAD as i32 { + break; + } + s = entry as usize; + i += 1; + } + } + // The character's position is `here` in bytes, or one unit before it + // for a pair's low half, whose encoding `i` has already passed. + let (here, half) = (i, pending != 0); + let c = if pending != 0 { + core::mem::replace(&mut pending, 0) + } else { + let Some(&lead) = bytes.get(i) else { + let entry = m[s]; + if entry == UNKNOWN { + return None; + } + if entry & FLAG != 0 { + last = Some(position(i)); + } + break; + }; + if lead < 0x80 { + i += 1; + u32::from(lead) + } else { + let (point, width) = decode(bytes, i); + i += width; + if point > 0xffff && !unicode { + pending = low_half(point); + high_half(point) + } else if unicode && (0xd800..0xdc00).contains(&point) { + return None; + } else { + point + } + } + }; + let column = if c < 128 { + table[c as usize] as usize + } else { + memo_column(m, memo, c)? + }; + let entry = m[s + column]; + if entry == UNKNOWN { + return None; + } + if entry & FLAG != 0 { + if first { + last = Some(0); + break; + } + last = Some(position(here) - usize::from(half)); + } + let next = (entry & !FLAG) as usize; + if next == DEAD as usize { + break; + } + s = next; + } + Some((last, i)) +} + +/// The scalar whose WTF-8 encoding begins with the non-ASCII byte at `i`, and +/// its width in bytes. Validated storage holds a whole encoding there. +#[inline(always)] +fn decode(bytes: &[u8], i: usize) -> (u32, usize) { + let lead = bytes[i]; + let tail = |k: usize| bytes.get(i + k).map_or(0, |&b| u32::from(b & 0x3f)); + if lead < 0xe0 { + ((u32::from(lead & 0x1f) << 6) | tail(1), 2) + } else if lead < 0xf0 { + ((u32::from(lead & 0x0f) << 12) | (tail(1) << 6) | tail(2), 3) + } else { + ( + (u32::from(lead & 0x07) << 18) | (tail(1) << 12) | (tail(2) << 6) | tail(3), + 4, + ) + } +} + +/// The UTF-16 units `bytes` encode, all of them whole WTF-8 sequences: one +/// for every byte that begins a sequence, and one more for every four-byte +/// one. +fn utf16_units(bytes: &[u8]) -> usize { + const HIGH: u64 = 0x8080_8080_8080_8080; + let (words, tail) = bytes.as_chunks::<8>(); + let mut units = 0; + for word in words { + let w = u64::from_le_bytes(*word); + // Continuation bytes are 10xxxxxx; four-byte leads are 11110xxx. + let continuation = w & !(w << 1) & HIGH; + let four = w & (w << 1) & (w << 2) & (w << 3) & HIGH; + units += 8 - continuation.count_ones() as usize + four.count_ones() as usize; + } + for &byte in tail { + units += usize::from(byte & 0xc0 != 0x80) + usize::from(byte >= 0xf0); + } + units +} + +/// The column of a non-ASCII character in the first slot of its memo probe, +/// or `None` when it is not there. +#[inline(always)] +fn memo_column(m: &[u32], memo: usize, c: u32) -> Option { + let at = memo + 2 * (hash2(c, 0x5bd1_e995) as usize & (MEMO_SLOTS - 1)); + (m[at] == c + 1).then(|| m[at + 1] as usize) +} + +/// Whether `m` holds a cache built for a program of this length and header. +/// The words compared on every call are those that tell programs apart most +/// often; a debug build compares the whole header. Neither is proof of +/// identity, which is the host's contract. +#[inline(always)] +fn built_for(words: &[u32; HEADER], len: usize, m: &[u32]) -> bool { + m.len() >= FRONT + && m[H_MAGIC] == MAGIC + && m[H_CAP] as usize == m.len() + && m[H_LEN] as usize == len + && m[H_PROGRAM] == words[2] + && m[H_PROGRAM + 2] == words[4] + && m[H_PROGRAM + 5] == words[7] + && m[H_LAYOUT] == LAYOUT + && (!cfg!(debug_assertions) || m[H_PROGRAM..H_PROGRAM + 9] == words[2..11]) +} + +struct Plan { + required: usize, + threads: usize, + pred: usize, + flags: usize, + rep: usize, + rps: usize, + rpl: usize, + reps: usize, + memo: usize, + sigtmp: usize, + sigw: usize, + table: usize, + table_slots: usize, + stack: usize, + out: usize, + saved: usize, + sig: usize, + sigcap: usize, + hash: usize, + hash_slots: usize, + region: usize, +} + +/// Where everything goes in `cap` words. `minimum` plans the smallest work +/// area, which is what [`minimum_words`] reports. +fn plan( + n: usize, + repeats: usize, + predicates: usize, + edges: usize, + cap: usize, + minimum: bool, +) -> Plan { + let sigw = (PRED_BASE + predicates).div_ceil(32); + let small = (n + 64).min(64); + let threads = if minimum { + small + } else { + // Repeat keys multiply the threads one instruction can hold, so the + // limit is a multiple of the program; the storage caps it. + (4 * (n + 64)).min((cap / 64).max(64)) + }; + let mut at = FRONT; + let pred = take(&mut at, n); + let flags = take(&mut at, n); + let rep = take(&mut at, repeats); + let rps = take(&mut at, n + 1); + let rpl = take(&mut at, edges); + let reps = take(&mut at, predicates); + let memo = take(&mut at, 2 * MEMO_SLOTS); + let sigtmp = take(&mut at, sigw); + let table_slots = (4 * threads).next_power_of_two(); + let table = take(&mut at, 2 * table_slots); + let stack = take(&mut at, 4 * threads); + let out = take(&mut at, 2 * threads); + let saved = take(&mut at, 2 * threads + 2); + let fixed = at; + let required = fixed + 16 * sigw + 64 + 8 * (FIRST_COLUMNS + 2 + 32); + let rest = cap.saturating_sub(fixed); + let sigcap = (rest / 8 / sigw).clamp(16, MAX_CLASSES); + let sig = take(&mut at, sigcap * sigw); + let rest = cap.saturating_sub(at); + let hash_slots = prev_power_of_two(rest / 16).max(64); + let hash = take(&mut at, hash_slots); + Plan { + required, + threads, + pred, + flags, + rep, + rps, + rpl, + reps, + memo, + sigtmp, + sigw, + table, + table_slots, + stack, + out, + saved, + sig, + sigcap, + hash, + hash_slots, + region: at, + } +} + +fn take(at: &mut usize, words: usize) -> usize { + let start = *at; + *at += words; + start +} + +fn prev_power_of_two(n: usize) -> usize { + if n == 0 { + 0 + } else { + 1 << (usize::BITS - 1 - n.leading_zeros()) + } +} + +/// The program's epsilon successors of `pc`, as `(count, [first, second])`. +/// A consuming instruction's step to the next one is not an epsilon edge. +fn successors(p: Program<'_>, pc: usize) -> (usize, [usize; 2]) { + let [op, a, b] = p.instruction(pc); + match op { + SAVE | START | START_M | END | END_M | WORD | WORD_I | REPEAT_INIT | ATOM_REPEAT + | REPEAT_BODY => (1, [pc + 1, 0]), + JUMP => (1, [a as usize, 0]), + SPLIT => (2, [a as usize, b as usize]), + BRANCH => (2, [pc + 1, b as usize]), + REPEAT_CHOICE => { + let r = p.repeat(a as usize); + (2, [r[3] as usize, r[4] as usize]) + } + REPEAT_NEXT => (1, [p.repeat(a as usize)[3] as usize - 1, 0]), + _ => (0, [0, 0]), + } +} + +fn hash2(a: u32, b: u32) -> u32 { + (a.wrapping_mul(0x9e37_79b1) ^ b.wrapping_mul(0x85eb_ca77).rotate_left(13)) + .wrapping_mul(0xc2b2_ae3d) +} + +/// Build the cache for `program` in `m`: tables derived from the program, and +/// no state yet. +fn build(program: Program<'_>, m: &mut [u32]) -> Result<(), Decline> { + let n = program.instructions(); + let repeats = program.words[6] as usize; + if m.len() >= FRONT { + m[H_MAGIC] = 0; + } + // State offsets share a word with the match flag in bit 31. + if m.len() >= MAX_WORDS { + return Err(Decline::Capacity); + } + let (edges, consuming_count) = census(program); + // Plan with every consuming instruction its own predicate first; the + // deduplicated count only shrinks the signature. A work area sized by the + // storage is preferred, the smallest one accepted. + let mut minimum = false; + let mut worst = plan(n, repeats, consuming_count, edges, m.len(), minimum); + if m.len() < worst.required { + minimum = true; + worst = plan(n, repeats, consuming_count, edges, m.len(), minimum); + if m.len() < worst.required { + return Err(Decline::Storage { + required_words: worst.required, + }); + } + } + // Deduplicate predicates through a temporary table at the storage's end, + // past everything the per-instruction tables use. + let pred_at = worst.pred; + let temp_slots = (2 * consuming_count).next_power_of_two().max(2); + let temp_at = m.len().checked_sub(2 * temp_slots); + let mut predicates = 0usize; + let dedupe = temp_at.is_some_and(|at| at >= worst.reps + consuming_count); + if dedupe { + let at = temp_at.unwrap(); + m[at..].fill(0); + for pc in 0..n { + let [op, a, b] = program.instruction(pc); + if !consuming(op) { + m[pred_at + pc] = 0; + continue; + } + let mut slot = hash2(hash2(op, a), b) as usize & (temp_slots - 1); + loop { + let rep = m[at + 2 * slot]; + if rep == 0 { + m[at + 2 * slot] = pc as u32 + 1; + m[at + 2 * slot + 1] = predicates as u32; + m[pred_at + pc] = predicates as u32 + 1; + predicates += 1; + break; + } + if program.instruction(rep as usize - 1) == [op, a, b] { + m[pred_at + pc] = m[at + 2 * slot + 1] + 1; + break; + } + slot = (slot + 1) & (temp_slots - 1); + } + } + } else { + for pc in 0..n { + if consuming(program.instruction(pc)[0]) { + predicates += 1; + m[pred_at + pc] = predicates as u32; + } else { + m[pred_at + pc] = 0; + } + } + } + let p = plan(n, repeats, predicates, edges, m.len(), minimum); + debug_assert_eq!(p.pred, pred_at); + // Representative instruction of each predicate: its first. + m[p.reps..p.reps + predicates].fill(u32::MAX); + for pc in 0..n { + let index = m[p.pred + pc] as usize; + if index != 0 && m[p.reps + index - 1] == u32::MAX { + m[p.reps + index - 1] = pc as u32; + } + } + // Repeat fields and the empty-check bits each instruction's consumption + // sets: those of every repeat whose body holds it. + m[p.flags..p.flags + n].fill(0); + m[p.rep..p.rep + repeats].fill(0); + let ok = dfa_fields(program, |id, offset, bits| { + m[p.rep + id] = offset | bits << 8; + let r = program.repeat(id); + let flag = 1u32 << (offset + bits); + for pc in r[3] as usize + 1..r[4] as usize - 1 { + m[p.flags + pc] |= flag; + } + }); + if !ok { + return Err(Decline::Ineligible); + } + // Reverse epsilon edges, as compressed rows by target. + m[p.rps..p.rps + n + 1].fill(0); + for pc in 0..n { + let (count, targets) = successors(program, pc); + for &target in &targets[..count] { + m[p.rps + target + 1] += 1; + } + } + for i in 0..n { + m[p.rps + i + 1] += m[p.rps + i]; + } + for pc in 0..n { + let (count, targets) = successors(program, pc); + for &target in &targets[..count] { + let at = m[p.rps + target] as usize; + m[p.rpl + at] = pc as u32; + m[p.rps + target] += 1; + } + } + for i in (1..=n).rev() { + m[p.rps + i] = m[p.rps + i - 1]; + } + m[p.rps] = 0; + // Context the program's assertions read. + let mut need = 0; + for pc in 0..n { + need |= match program.instruction(pc)[0] { + START | END => CX_EDGE, + START_M | END_M => CX_EDGE | CX_LT, + WORD => CX_WORD, + WORD_I if program.unicode() => CX_WORD_UI, + WORD_I => CX_WORD, + _ => 0, + }; + } + let ascii_start = ascii_start(program, m, p.hash); + m[ASCII..SLOTS].fill(UNCLASSIFIED); + m[SLOTS..FRONT].fill(0); + m[p.memo..p.memo + 2 * MEMO_SLOTS].fill(0); + m[p.table..p.table + 2 * p.table_slots].fill(0); + m[p.hash..p.hash + p.hash_slots].fill(0); + let cap = m.len() as u32; + let header = &mut m[..HEADER_WORDS]; + header.fill(0); + header[H_LAYOUT] = LAYOUT; + header[H_LEN] = program.words.len() as u32; + header[H_PROGRAM..H_PROGRAM + 9].copy_from_slice(&program.words[2..11]); + header[H_CAP] = cap; + header[H_COLUMNS] = FIRST_COLUMNS as u32; + header[H_PREDICATES] = predicates as u32; + header[H_SIGW] = p.sigw as u32; + header[H_NEED] = need; + header[H_BUMP] = p.region as u32; + header[H_PEAK] = p.region as u32; + header[H_GEN] = 1; + header[H_THREADS] = p.threads as u32; + header[H_PRED] = p.pred as u32; + header[H_FLAGS] = p.flags as u32; + header[H_REP] = p.rep as u32; + header[H_RPS] = p.rps as u32; + header[H_RPL] = p.rpl as u32; + header[H_REPS] = p.reps as u32; + header[H_MEMO] = p.memo as u32; + header[H_SIGTMP] = p.sigtmp as u32; + header[H_TABLE] = p.table as u32; + header[H_TABLE_SLOTS] = p.table_slots as u32; + header[H_STACK] = p.stack as u32; + header[H_OUT] = p.out as u32; + header[H_SAVED] = p.saved as u32; + header[H_SIG] = p.sig as u32; + header[H_SIGCAP] = p.sigcap as u32; + header[H_HASH] = p.hash as u32; + header[H_HASH_SLOTS] = p.hash_slots as u32; + header[H_REGION] = p.region as u32; + header[H_ASCII_START] = u32::from(ascii_start); + let pairs = match program.leading() { + 0 => Prefix { + first: [0; LEADING_BRANCHES], + second: [0; LEADING_BRANCHES], + members: 0, + }, + leading => prefix(program, leading), + }; + header[H_PAIRS] = pairs.members as u32; + for (k, chunk) in pairs + .first + .chunks(4) + .chain(pairs.second.chunks(4)) + .enumerate() + { + header[H_PAIRS + 1 + k] = u32::from_le_bytes(chunk.try_into().unwrap_or([0; 4])); + } + header[H_MAGIC] = MAGIC; + Ok(()) +} + +/// Whether every consuming instruction reachable from instruction zero +/// without consuming accepts only ASCII characters, so a character that is +/// not ASCII can begin no match. Counters are ignored, which can only admit +/// more instructions. The walk uses the storage from `free` on as scratch, +/// and answers no when it does not fit there. +fn ascii_start(program: Program<'_>, m: &mut [u32], free: usize) -> bool { + let n = program.instructions(); + let Some(seen) = m.len().checked_sub(2 * n).filter(|&at| at >= free) else { + return false; + }; + let stack = seen + n; + m[seen..stack].fill(0); + m[seen] = 1; + m[stack] = 0; + let mut depth = 1; + while depth != 0 { + depth -= 1; + let pc = m[stack + depth] as usize; + let [op, a, b] = program.instruction(pc); + if consuming(op) { + let ascii = match op { + CHAR => a < 128, + CHAR_I => casefold::equivalents(a, program.unicode()) + .iter() + .all(|&c| c < 128), + CLASS | CLASS_SORTED => { + b & NEGATED == 0 + && (a..a + b).all(|index| { + let [lo, hi] = program.range(index as usize); + lo & PROPERTY == 0 && hi < 128 + }) + } + _ => false, + }; + if !ascii { + return false; + } + continue; + } + let (count, targets) = successors(program, pc); + for &target in &targets[..count] { + if m[seen + target] == 0 { + m[seen + target] = 1; + m[stack + depth] = target as u32; + depth += 1; + } + } + } + true +} + +fn high_half(point: u32) -> u32 { + 0xd800 | ((point - 0x10000) >> 10) +} + +fn low_half(point: u32) -> u32 { + 0xdc00 | ((point - 0x10000) & 0x3ff) +} + +fn line_terminator(c: u32) -> bool { + matches!(c, 10 | 13 | 0x2028 | 0x2029) +} + +fn read(cursor: &mut Cursor<'_>, unicode: bool, reverse: bool) -> Option { + match (unicode, reverse) { + (true, false) => cursor.next_point(), + (true, true) => cursor.previous_point(), + (false, false) => cursor.next_unit().map(u32::from), + (false, true) => cursor.previous_unit().map(u32::from), + } +} + +/// Whether a consuming instruction accepts `c`, by the evaluator's rules. +fn accepts(program: Program<'_>, [op, a, b]: [u32; 3], c: u32) -> bool { + match op { + CHAR => c == a, + CHAR_I => c == a || casefold::equal(c, a, program.unicode()), + ANY => !line_terminator(c), + ANY_S => true, + CLASS | CLASS_SORTED => class(program, a, b, [c; 4], op == CLASS_SORTED), + CLASS_I | CLASS_SORTED_I => class( + program, + a, + b, + casefold::equivalents(c, program.unicode()), + op == CLASS_SORTED_I, + ), + _ => false, + } +} + +/// Class membership for a character's case equivalents, as the evaluator's +/// class step decides it, including a `v` complement's whole-closure rule. +fn class(program: Program<'_>, a: u32, b: u32, values: [u32; 4], sorted: bool) -> bool { + let (start, end, negated) = (a, a + (b & !NEGATED), b & NEGATED != 0); + let found = if sorted { + values.iter().any(|&value| { + let (mut low, mut high) = (start, end); + while low < high { + let middle = low + (high - low) / 2; + let [lo, hi] = program.range(middle as usize); + if value < lo { + high = middle; + } else if value > hi { + low = middle + 1; + } else { + return true; + } + } + false + }) + } else { + (start..end).any(|index| { + let [lo, hi] = program.range(index as usize); + if lo & PROPERTY != 0 && hi == 2 { + return !values + .iter() + .any(|&value| properties::contains(lo & !PROPERTY, value)); + } + values.iter().any(|&value| { + if lo & PROPERTY != 0 { + properties::contains(lo & !PROPERTY, value) != (hi != 0) + } else { + value >= lo && value <= hi + } + }) + }) + }; + found != negated +} + +/// Whether a position assertion holds between `before` and `after`. +fn holds(op: u32, a: u32, before: u32, after: u32, unicode: bool) -> bool { + match op { + START => before & CX_EDGE != 0, + START_M => before & (CX_EDGE | CX_LT) != 0, + END => after & CX_EDGE != 0, + END_M => after & (CX_EDGE | CX_LT) != 0, + WORD | WORD_I => { + let bit = if op == WORD_I && unicode { + CX_WORD_UI + } else { + CX_WORD + }; + ((before & bit != 0) != (after & bit != 0)) != (a != 0) + } + _ => false, + } +} + +/// The context bits of one character. +fn context(c: u32) -> u32 { + (if line_terminator(c) { CX_LT } else { 0 }) + | (if casefold::word(c, false) { CX_WORD } else { 0 }) + | (if casefold::word(c, true) { + CX_WORD_UI + } else { + 0 + }) +} + +struct Dfa<'p, 'c> { + program: Program<'p>, + m: &'c mut [u32], + budget: Budget, + /// Characters this search has read, and how many of them the header's + /// count since the last clear already holds. + progress: usize, + flushed: usize, + unicode: bool, +} + +impl Dfa<'_, '_> { + #[inline(always)] + fn h(&self, index: usize) -> usize { + self.m[index] as usize + } + + fn charge(&mut self, work: usize) -> Result<(), Stop> { + self.budget + .charge(work) + .map_err(|_| Stop::Error(ExecError::WorkLimit)) + } + + #[inline(always)] + fn flush_units(&mut self) { + if self.progress == self.flushed { + return; + } + let total = (self.m[H_UNITS] as usize).saturating_add(self.progress - self.flushed); + self.m[H_UNITS] = total.min(u32::MAX as usize) as u32; + self.flushed = self.progress; + } + + #[inline(always)] + fn run(&mut self, input: Input<'_>, start: usize, bounds: bool) -> Result { + let mut cursor = input.cursor_at(start).ok_or(ExecError::InvalidProgram)?; + if self.unicode && start != 0 { + cursor.normalize_unicode_start(); + } + let start = cursor.position(); + let anchored = self.program.words[2] & Y != 0 || self.program.start_anchored(); + let Some(end) = self.forward(input, cursor, anchored, !bounds)? else { + return Ok(Outcome::NoMatch); + }; + if !bounds { + return Ok(Outcome::Matched(None)); + } + let begin = if anchored { + start + } else { + self.reverse(input, end, start)? + }; + Ok(Outcome::Matched(Span::new(begin, end))) + } + + /// The context of the character before `cursor`, as the assertions read + /// it: `previous_unit` for a line terminator, the evaluator's own read for + /// a word character. + #[inline(always)] + fn before(&self, cursor: Cursor<'_>) -> u32 { + let need = self.m[H_NEED]; + if need == 0 { + return 0; + } + if cursor.position() == 0 { + return CX_EDGE & need; + } + self.before_character(cursor) + } + + #[inline(never)] + fn before_character(&self, cursor: Cursor<'_>) -> u32 { + let mut unit = cursor; + let mut point = cursor; + let lt = unit + .previous_unit() + .is_some_and(|u| line_terminator(u32::from(u))); + let c = read(&mut point, self.unicode, true).unwrap_or(0); + let bits = (context(c) & !CX_LT) | if lt { CX_LT } else { 0 }; + bits & self.m[H_NEED] + } + + fn after(&self, cursor: Cursor<'_>, len: usize) -> u32 { + if cursor.position() == len { + return CX_EDGE & self.m[H_NEED]; + } + let mut unit = cursor; + let mut point = cursor; + let lt = unit + .next_unit() + .is_some_and(|u| line_terminator(u32::from(u))); + let c = read(&mut point, self.unicode, false).unwrap_or(0); + let bits = (context(c) & !CX_LT) | if lt { CX_LT } else { 0 }; + bits & self.m[H_NEED] + } + + /// The end of the leftmost-first match at or after `cursor`, or of the + /// first match end found when `first` is set. + #[inline(always)] + fn forward( + &mut self, + input: Input<'_>, + mut cursor: Cursor<'_>, + anchored: bool, + first: bool, + ) -> Result, Stop> { + let ctx = self.before(cursor); + let mode = if anchored { + MODE_ANCHORED + } else { + MODE_UNANCHORED + }; + let mut s = self.start_state(mode, ctx)?; + let mut last = None; + let allowance = self.budget.remaining(); + if let Some(bytes) = input.ascii_bytes() { + let begin = cursor.position(); + let base = self.progress; + let limit = bytes.len().min(begin.saturating_add(allowance)); + let mut i = begin; + loop { + let Some(&byte) = bytes.get(i) else { + let mut entry = self.m[s as usize]; + if entry == UNKNOWN { + self.progress = base + (i - begin); + entry = self.step_slow(&mut s, None)?; + } + if entry & FLAG != 0 { + last = Some(i); + } + break; + }; + if i == limit { + self.progress = base + (i - begin); + return Err(Stop::Error(ExecError::WorkLimit)); + } + let column = self.m[ASCII + byte as usize] as usize; + let mut entry = self.m[s as usize + column]; + if entry == UNKNOWN { + self.progress = base + (i - begin); + entry = self.step_slow(&mut s, Some(u32::from(byte)))?; + } + if entry & FLAG != 0 { + last = Some(i); + if first { + break; + } + } + let next = entry & !FLAG; + if next == DEAD { + break; + } + s = next; + i += 1; + } + self.progress = base + (i - begin); + self.charge(i - begin)?; + return Ok(last); + } + // Other storage is read a code point at a time, which decodes a + // four-byte scalar once; without `u` its two halves are then taken + // as the two characters they are. + let base = self.progress; + let mut read_units = 0; + let mut at = cursor.position(); + let mut pending = None; + loop { + let c = match pending.take() { + Some(low) => Some(low), + None => match cursor.next_point() { + Some(point) if point > 0xffff && !self.unicode => { + pending = Some(low_half(point)); + Some(high_half(point)) + } + other => other, + }, + }; + if c.is_some() { + read_units += 1; + if read_units > allowance { + return Err(Stop::Error(ExecError::WorkLimit)); + } + } + let entry = match self.cached(s, c) { + UNKNOWN => { + self.progress = base + read_units; + self.step_slow(&mut s, c)? + } + entry => entry, + }; + if entry & FLAG != 0 { + last = Some(at); + if first { + break; + } + } + let next = entry & !FLAG; + let Some(c) = c else { break }; + if next == DEAD { + break; + } + s = next; + at += if c > 0xffff { 2 } else { 1 }; + } + self.progress = base + read_units; + self.charge(read_units)?; + Ok(last) + } + + /// The leftmost start, at or after `lower`, of a match ending at `end`. + fn reverse(&mut self, input: Input<'_>, end: usize, lower: usize) -> Result { + let mut cursor = input.cursor_at(end).ok_or(ExecError::InvalidProgram)?; + let ctx = self.after(cursor, input.len_utf16()); + let mut s = self.start_state(MODE_REVERSE, ctx)?; + let mut found = None; + let base = self.progress; + let mut read_units = 0; + let mut at = end; + let mut pending = None; + loop { + let c = match pending.take() { + Some(high) => Some(high), + None if at == 0 => None, + None => match cursor.previous_point() { + Some(point) if point > 0xffff && !self.unicode => { + pending = Some(high_half(point)); + Some(low_half(point)) + } + other => other, + }, + }; + read_units += 1; + let entry = match self.cached(s, c) { + UNKNOWN => { + self.progress = base + read_units; + self.step_slow(&mut s, c)? + } + entry => entry, + }; + if entry & FLAG != 0 { + found = Some(at); + } + let next = entry & !FLAG; + let Some(c) = c else { break }; + if at <= lower || next == DEAD { + break; + } + s = next; + at -= if c > 0xffff { 2 } else { 1 }; + } + self.progress = base + read_units; + self.charge(read_units)?; + found.ok_or(Stop::Error(ExecError::InvalidProgram)) + } + + /// The cached transition from `s` on `c`, or `UNKNOWN`: a character whose + /// class is in the ASCII table or the first slot of its memo probe. + #[inline(always)] + fn cached(&self, s: u32, c: Option) -> u32 { + let column = match c { + None => 0, + Some(c) if c < 128 => self.m[ASCII + c as usize] as usize, + Some(c) => { + let at = self.h(H_MEMO) + 2 * (hash2(c, 0x5bd1_e995) as usize & (MEMO_SLOTS - 1)); + if self.m[at] != c + 1 { + return UNKNOWN; + } + self.m[at + 1] as usize + } + }; + self.m[s as usize + column] + } + + /// The transition from `s` on `c`, or on the subject's edge for `None`, + /// when [`Dfa::cached`] does not have it: finding the class, computing the + /// transition. A clear while computing it moves `s`. + #[inline(never)] + fn step_slow(&mut self, s: &mut u32, c: Option) -> Result { + let column = match c { + None => 0, + Some(c) => self.column(c, s)?, + }; + let entry = self.m[*s as usize + column]; + if entry != UNKNOWN { + return Ok(entry); + } + self.compute(s, column) + } + + /// The class of `c`, assigning a new one on first sight. + fn column(&mut self, c: u32, s: &mut u32) -> Result { + if c < 128 { + let known = self.m[ASCII + c as usize]; + if known != UNCLASSIFIED { + return Ok(known as usize); + } + } else { + let memo = self.h(H_MEMO); + let mut slot = hash2(c, 0x5bd1_e995) as usize & (MEMO_SLOTS - 1); + loop { + let key = self.m[memo + 2 * slot]; + if key == c + 1 { + return Ok(self.m[memo + 2 * slot + 1] as usize); + } + if key == 0 { + break; + } + slot = (slot + 1) & (MEMO_SLOTS - 1); + } + } + let class = self.classify(c, s)?; + if c < 128 { + self.m[ASCII + c as usize] = class as u32; + } else { + let memo = self.h(H_MEMO); + if self.m[H_MEMO_USED] as usize >= MEMO_SLOTS * 3 / 4 { + self.m[memo..memo + 2 * MEMO_SLOTS].fill(0); + self.m[H_MEMO_USED] = 0; + } + let mut slot = hash2(c, 0x5bd1_e995) as usize & (MEMO_SLOTS - 1); + while self.m[memo + 2 * slot] != 0 { + slot = (slot + 1) & (MEMO_SLOTS - 1); + } + self.m[memo + 2 * slot] = c + 1; + self.m[memo + 2 * slot + 1] = class as u32; + self.m[H_MEMO_USED] += 1; + } + Ok(class) + } + + /// Evaluate every predicate on `c` and find or add its class. + fn classify(&mut self, c: u32, s: &mut u32) -> Result { + let sigw = self.h(H_SIGW); + let tmp = self.h(H_SIGTMP); + let predicates = self.h(H_PREDICATES); + let reps = self.h(H_REPS); + self.charge(predicates + 1)?; + self.m[tmp..tmp + sigw].fill(0); + self.m[tmp] = context(c) & self.m[H_NEED] & !CX_EDGE; + for index in 0..predicates { + let pc = self.h(reps + index); + if accepts(self.program, self.program.instruction(pc), c) { + let bit = PRED_BASE + index; + self.m[tmp + bit / 32] |= 1 << (bit % 32); + } + } + let sig = self.h(H_SIG); + let classes = self.h(H_CLASSES); + for class in 0..classes { + let at = sig + class * sigw; + if self.m[at..at + sigw] == self.m[tmp..tmp + sigw] { + return Ok(class + 2); + } + } + if classes >= self.h(H_SIGCAP) { + return Err(Stop::Declined(Decline::Capacity)); + } + let at = sig + classes * sigw; + self.m.copy_within(tmp..tmp + sigw, at); + self.m[H_CLASSES] = classes as u32 + 1; + let class = classes + 2; + if class >= self.h(H_COLUMNS) { + // Rows are as wide as the classes; widen them all, which drops + // every state, and rebuild the current one. + let columns = (self.h(H_COLUMNS) * 2).min(MAX_CLASSES + 2); + self.save(*s); + self.m[H_COLUMNS] = columns as u32; + self.clear(false)?; + *s = self.restore()?; + } + Ok(class) + } + + /// Copy state `s`'s header and threads to the saved area. + fn save(&mut self, s: u32) { + let columns = self.h(H_COLUMNS); + let at = s as usize + columns; + let count = self.h(at + 1); + let saved = self.h(H_SAVED); + self.m.copy_within(at..at + 2 + 2 * count, saved); + } + + /// Intern the saved state again after a clear. + fn restore(&mut self) -> Result { + let saved = self.h(H_SAVED); + let header = self.m[saved]; + let count = self.h(saved + 1); + match self.intern(header, saved + 2, count)? { + Some(s) => Ok(s), + None => Err(Stop::Declined(Decline::Capacity)), + } + } + + /// Drop every state. `space` marks a clear forced by a full cache, which + /// counts towards thrashing. + fn clear(&mut self, space: bool) -> Result<(), Stop> { + self.flush_units(); + if space { + let units = self.m[H_UNITS]; + let built = self.m[H_BUILT]; + if units < built.saturating_mul(UNITS_PER_STATE) { + self.m[H_BAD] += 1; + } else { + self.m[H_BAD] = 0; + } + if self.m[H_BAD] >= THRASH_LIMIT { + self.m[H_STATUS] = 1; + } + self.m[H_CLEARS] = self.m[H_CLEARS].saturating_add(1); + } + self.m[H_UNITS] = 0; + self.m[H_BUILT] = 0; + self.m[H_STATES] = 0; + self.m[H_BUMP] = self.m[H_REGION]; + self.m[SLOTS..FRONT].fill(0); + let hash = self.h(H_HASH); + let slots = self.h(H_HASH_SLOTS); + self.m[hash..hash + slots].fill(0); + if self.m[H_STATUS] != 0 { + return Err(Stop::Declined(Decline::Disabled)); + } + Ok(()) + } + + /// The state with `header` and the `count` threads at `threads`, built if + /// new. `None` when the cache has no room for it. + fn intern(&mut self, header: u32, threads: usize, count: usize) -> Result, Stop> { + if count == 0 && header & S_INJECT == 0 { + return Ok(Some(DEAD)); + } + let mut h = hash2(header, count as u32); + for i in 0..2 * count { + h = hash2(h, self.m[threads + i]); + } + let columns = self.h(H_COLUMNS); + let hash = self.h(H_HASH); + let slots = self.h(H_HASH_SLOTS); + let mut slot = h as usize & (slots - 1); + loop { + let s = self.h(hash + slot); + if s == 0 { + break; + } + let at = s + columns; + if self.m[at] == header + && self.h(at + 1) == count + && self.m[at + 2..at + 2 + 2 * count] == self.m[threads..threads + 2 * count] + { + return Ok(Some(s as u32)); + } + slot = (slot + 1) & (slots - 1); + } + let size = columns + 2 + 2 * count; + let s = self.h(H_BUMP); + if s + size > self.m.len() || self.h(H_STATES) + 1 > slots / 2 { + return Ok(None); + } + self.charge(size / 8 + 1)?; + self.m[s..s + columns].fill(UNKNOWN); + self.m[s + columns] = header; + self.m[s + columns + 1] = count as u32; + self.m + .copy_within(threads..threads + 2 * count, s + columns + 2); + self.m[hash + slot] = s as u32; + self.m[H_BUMP] = (s + size) as u32; + if self.m[H_PEAK] < self.m[H_BUMP] { + self.m[H_PEAK] = self.m[H_BUMP]; + } + self.m[H_STATES] += 1; + self.m[H_BUILT] += 1; + Ok(Some(s as u32)) + } + + #[inline(always)] + fn start_state(&mut self, mode: usize, ctx: u32) -> Result { + let slot = SLOTS + mode * 16 + ctx as usize; + let known = self.m[slot]; + if known != 0 { + return Ok(known); + } + self.build_start(mode, ctx) + } + + #[inline(never)] + fn build_start(&mut self, mode: usize, ctx: u32) -> Result { + let slot = SLOTS + mode * 16 + ctx as usize; + let saved = self.h(H_SAVED); + let (header, count) = match mode { + MODE_UNANCHORED => (S_INJECT | ctx << 4, 0), + MODE_ANCHORED => { + self.m[saved + 2] = 0; + self.m[saved + 3] = 0; + (ctx << 4, 1) + } + _ => { + self.m[saved + 2] = self.program.instructions() as u32 - 1; + self.m[saved + 3] = 0; + (S_REVERSE | ctx << 4, 1) + } + }; + self.m[saved] = header; + self.m[saved + 1] = count; + let s = match self.intern(header, saved + 2, count as usize)? { + Some(s) => s, + None => { + self.clear(true)?; + self.restore()? + } + }; + self.m[slot] = s; + Ok(s) + } + + /// Begin a closure: a fresh visited generation. + fn next_generation(&mut self) { + let mut generation = self.m[H_GEN] + 1; + if generation >= 128 { + let table = self.h(H_TABLE); + let slots = self.h(H_TABLE_SLOTS); + self.m[table..table + 2 * slots].fill(0); + generation = 1; + } + self.m[H_GEN] = generation; + } + + /// Mark `(pc, key)` visited; false when it already was in this generation. + #[inline] + fn visit(&mut self, pc: u32, key: u32, kind: u32, visited: &mut usize) -> Result { + let table = self.h(H_TABLE); + let slots = self.h(H_TABLE_SLOTS); + let tag = self.m[H_GEN] << GEN_SHIFT; + let word = pc | tag | kind; + let mut slot = hash2(pc | kind, key) as usize & (slots - 1); + loop { + let at = table + 2 * slot; + let current = self.m[at]; + if current & !(OUT_KEY | PC_MASK) != tag { + if *visited >= slots / 2 { + return Err(Stop::Declined(Decline::Capacity)); + } + *visited += 1; + self.m[at] = word; + self.m[at + 1] = key; + return Ok(true); + } + if current == word && self.m[at + 1] == key { + return Ok(false); + } + slot = (slot + 1) & (slots - 1); + } + } + + fn field(&self, id: u32) -> (u32, u32) { + let packed = self.m[self.h(H_REP) + id as usize]; + (packed & 255, (packed >> 8) & 255) + } + + /// Compute the transition from `s` on column `column` (zero: the edge), + /// store it, and return it. A clear while storing it moves `s`. + fn compute(&mut self, s: &mut u32, column: usize) -> Result { + let columns = self.h(H_COLUMNS); + let at = *s as usize + columns; + let header = self.m[at]; + let count = self.h(at + 1); + let reverse = header & S_REVERSE != 0; + let inject = header & S_INJECT != 0; + let ctx = (header >> 4) & 15; + let need = self.m[H_NEED]; + let (side, sig) = if column == 0 { + (CX_EDGE & need, 0) + } else { + let sig = self.h(H_SIG) + (column - 2) * self.h(H_SIGW); + (self.m[sig] & need, sig) + }; + let (before, after) = if reverse { (side, ctx) } else { (ctx, side) }; + let limit = self.h(H_THREADS); + let stack = self.h(H_STACK); + let out = self.h(H_OUT); + let pred = self.h(H_PRED); + let flags_at = self.h(H_FLAGS); + let unicode = self.unicode; + let program = self.program; + self.next_generation(); + let mut visited = 0usize; + let mut depth; + let mut produced = 0usize; + let mut flagged = false; + let mut work = 0usize; + let seeds = count + usize::from(inject); + 'seeds: for seed in 0..seeds { + let (pc, key) = if seed < count { + (self.m[at + 2 + 2 * seed], self.m[at + 3 + 2 * seed]) + } else { + (0, 0) + }; + self.m[stack] = pc; + self.m[stack + 1] = key; + depth = 1; + while depth > 0 { + depth -= 1; + let pc = self.m[stack + 2 * depth]; + let key = self.m[stack + 2 * depth + 1]; + if !self.visit(pc, key, 0, &mut visited)? { + continue; + } + work += 1; + let mut push = |m: &mut [u32], pc: u32, key: u32| -> Result<(), Stop> { + if depth >= 2 * limit { + return Err(Stop::Declined(Decline::Capacity)); + } + m[stack + 2 * depth] = pc; + m[stack + 2 * depth + 1] = key; + depth += 1; + Ok(()) + }; + if reverse { + if pc == 0 { + flagged = true; + } + // A consuming instruction just before `pc` reads the + // character before this position. + if column != 0 && pc > 0 { + let index = self.m[pred + pc as usize - 1]; + if index != 0 { + let bit = PRED_BASE + index as usize - 1; + if self.m[sig + bit / 32] & (1 << (bit % 32)) != 0 + && self.visit(pc - 1, key, OUT_KEY, &mut visited)? + { + if produced >= limit { + return Err(Stop::Declined(Decline::Capacity)); + } + self.m[out + 2 * produced] = pc - 1; + self.m[out + 2 * produced + 1] = key; + produced += 1; + } + } + } + let rps = self.h(H_RPS); + let rpl = self.h(H_RPL); + let (from, to) = (self.h(rps + pc as usize), self.h(rps + pc as usize + 1)); + for e in from..to { + let q = self.m[rpl + e]; + let [op, a, _] = program.instruction(q as usize); + match op { + SAVE | JUMP | SPLIT | BRANCH | REPEAT_BODY => push(self.m, q, key)?, + START | START_M | END | END_M | WORD | WORD_I => { + if holds(op, a, before, after, unicode) { + push(self.m, q, key)?; + } + } + REPEAT_INIT | ATOM_REPEAT => { + let (offset, bits) = self.field(a); + let k = (key >> offset) & ((1 << bits) - 1); + let r = program.repeat(a as usize); + if k >= r[0] && (r[2] & 1 != 0 || k <= r[1]) { + let mask = ((1u32 << (bits + 1)) - 1) << offset; + push(self.m, q, key & !mask)?; + } + } + REPEAT_CHOICE => { + let r = program.repeat(a as usize); + if pc == r[4] { + let (offset, bits) = self.field(a); + let mask = ((1u32 << (bits + 1)) - 1) << offset; + push(self.m, q, key & !mask)?; + } else { + push(self.m, q, key)?; + } + } + REPEAT_NEXT => { + let (offset, bits) = self.field(a); + let count_mask = ((1u32 << bits) - 1) << offset; + let k = (key & count_mask) >> offset; + let r = program.repeat(a as usize); + let next = if r[2] & 1 != 0 { + (k + 1).min(r[0]) + } else { + k + 1 + }; + if r[2] & 1 != 0 || next <= r[1] { + push(self.m, q, (key & !count_mask) | (next << offset))?; + } + } + _ => {} + } + } + continue; + } + let [op, a, b] = program.instruction(pc as usize); + match op { + MATCH => { + flagged = true; + break 'seeds; + } + SAVE => push(self.m, pc + 1, key)?, + JUMP => push(self.m, a, key)?, + SPLIT => { + push(self.m, b, key)?; + push(self.m, a, key)?; + } + BRANCH => { + push(self.m, b, key)?; + push(self.m, pc + 1, key)?; + } + START | START_M | END | END_M | WORD | WORD_I => { + if holds(op, a, before, after, unicode) { + push(self.m, pc + 1, key)?; + } + } + REPEAT_INIT | ATOM_REPEAT => { + let (offset, bits) = self.field(a); + let mask = ((1u32 << (bits + 1)) - 1) << offset; + push(self.m, pc + 1, key & !mask)?; + } + REPEAT_CHOICE => { + let r = program.repeat(a as usize); + let (offset, bits) = self.field(a); + let k = (key >> offset) & ((1 << bits) - 1); + let mask = ((1u32 << (bits + 1)) - 1) << offset; + let exit = key & !mask; + if r[2] & 1 == 0 && k >= r[1] { + push(self.m, r[4], exit)?; + } else if k >= r[0] { + if r[2] & 2 == 0 { + push(self.m, r[4], exit)?; + push(self.m, pc + 1, key)?; + } else { + push(self.m, r[3], key)?; + push(self.m, r[4], exit)?; + } + } else { + push(self.m, pc + 1, key)?; + } + } + REPEAT_BODY => { + let (offset, bits) = self.field(a); + push(self.m, pc + 1, key & !(1 << (offset + bits)))?; + } + REPEAT_NEXT => { + let r = program.repeat(a as usize); + let (offset, bits) = self.field(a); + let count_mask = ((1u32 << bits) - 1) << offset; + let k = (key & count_mask) >> offset; + let consumed = key & (1 << (offset + bits)) != 0; + if consumed || k < r[0] { + let next = if r[2] & 1 != 0 { + (k + 1).min(r[0]) + } else { + k + 1 + }; + push( + self.m, + r[3] - 1, + (key & !count_mask) | ((next << offset) & count_mask), + )?; + } + } + _ if column != 0 => { + let index = self.m[pred + pc as usize]; + if index == 0 { + return Err(Stop::Error(ExecError::InvalidProgram)); + } + let bit = PRED_BASE + index as usize - 1; + if self.m[sig + bit / 32] & (1 << (bit % 32)) != 0 { + let next = key | self.m[flags_at + pc as usize]; + if self.visit(pc + 1, next, OUT_KEY, &mut visited)? { + if produced >= limit { + return Err(Stop::Declined(Decline::Capacity)); + } + self.m[out + 2 * produced] = pc + 1; + self.m[out + 2 * produced + 1] = next; + produced += 1; + } + } + } + _ => {} + } + } + } + self.charge(work)?; + let entry = if column == 0 { + DEAD + } else { + let header = if reverse { + S_REVERSE + } else if inject && !flagged { + S_INJECT + } else { + 0 + } | side << 4; + match self.intern(header, out, produced)? { + Some(next) => next, + None => { + // Full: keep this state and its successor, drop the rest. + self.save(*s); + self.clear(true)?; + *s = self.restore()?; + match self.intern(header, out, produced)? { + Some(next) => next, + None => return Err(Stop::Declined(Decline::Capacity)), + } + } + } + }; + let entry = entry | if flagged { FLAG } else { 0 }; + self.m[*s as usize + column] = entry; + Ok(entry) + } +} + +#[cfg(test)] +mod tests { + extern crate std; + use super::*; + use crate::compiler::{Node, Range, compile}; + use std::{vec, vec::Vec}; + + fn program(pattern: &str, flags: &str) -> Vec { + let source = Input::utf8(pattern); + let mut nodes = vec![Node::default(); 256]; + let mut ranges = vec![Range::default(); 1024]; + let mut words = vec![0; 4096]; + let p = compile( + source, + flags, + &mut nodes, + &mut ranges, + &mut words, + &mut Budget::new(10_000_000), + ) + .unwrap(); + p.words().to_vec() + } + + /// Generalized UTF-8: surrogates are encoded as three bytes each. + fn wtf8(points: &[u32]) -> Vec { + let mut bytes = Vec::new(); + for &c in points { + match c { + 0..0x80 => bytes.push(c as u8), + 0x80..0x800 => bytes.extend([0xc0 | (c >> 6) as u8, 0x80 | (c & 0x3f) as u8]), + 0x800..0x10000 => bytes.extend([ + 0xe0 | (c >> 12) as u8, + 0x80 | ((c >> 6) & 0x3f) as u8, + 0x80 | (c & 0x3f) as u8, + ]), + _ => bytes.extend([ + 0xf0 | (c >> 18) as u8, + 0x80 | ((c >> 12) & 0x3f) as u8, + 0x80 | ((c >> 6) & 0x3f) as u8, + 0x80 | (c & 0x3f) as u8, + ]), + } + } + bytes + } + + #[test] + fn the_steady_decoder_reads_every_scalar() { + for c in 0x80..=0x10ffff { + let bytes = wtf8(&[c]); + assert_eq!(decode(&bytes, 0), (c, bytes.len()), "U+{c:04X}"); + } + } + + #[test] + fn utf16_units_count_every_encoding() { + let points = [0x41, 0xe9, 0x4e2d, 0xd83d, 0xde00, 0x1f600, 0x7f, 0x10ffff]; + for from in 0..points.len() { + for to in from..=points.len() { + let units: usize = points[from..to] + .iter() + .map(|&c| if c > 0xffff { 2 } else { 1 }) + .sum(); + let mut bytes = wtf8(&points[from..to]); + assert_eq!(utf16_units(&bytes), units); + // Long enough for the word loop. + bytes = bytes.repeat(5); + assert_eq!(utf16_units(&bytes), 5 * units); + } + } + } + + /// Characters of two, three and four bytes, and a pair encoded as two + /// separate surrogates, each landing on a class the cache already holds, + /// are classified by the steady path itself: it answers without leaving + /// to the general path, and its answer is the class's. A misdecoded value + /// either misses the memo, and the steady path declines, or lands on + /// another class, and the answer changes; both fail here. + #[test] + fn the_steady_path_classifies_wide_characters_it_has_seen() { + let pair = [0xd83d, 0xde00]; + for (pattern, flags, hit, miss) in [ + ("é", "", vec![0xe9], vec![0xe8]), + ("中", "", vec![0x4e2d], vec![0x472d]), + ("中", "u", vec![0x4e2d], vec![0x4e2e]), + ("\\u{1F600}", "u", vec![0x1f600], vec![0x1f5c0]), + ("\\uD83D\\uDE00", "", vec![0x1f600], vec![0x1f601]), + ("\\uD83D\\uDE00", "", pair.to_vec(), vec![0xd83d, 0xde01]), + ] { + let words = program(pattern, flags); + let p = Program::from_words(&words, &mut Budget::new(1_000_000)).unwrap(); + let mut cache = vec![0u32; 1 << 15]; + let subjects = [ + [&[0x78][..], &hit, &[0x79]].concat(), + [&[0x78][..], &miss, &[0x79]].concat(), + ]; + let width = |points: &[u32]| -> usize { + points.iter().map(|&c| if c > 0xffff { 2 } else { 1 }).sum() + }; + // Build the states and classes both subjects meet. + for points in &subjects { + let bytes = wtf8(points); + let input = Input::wtf8(&bytes).unwrap(); + for _ in 0..2 { + find(p, input, 0, &mut cache, &mut Budget::new(1_000_000)).unwrap(); + } + } + for (points, matched) in subjects.iter().zip([true, false]) { + let bytes = wtf8(points); + let input = Input::wtf8(&bytes).unwrap(); + let anchored = p.words[2] & Y != 0 || p.start_anchored(); + for first in [true, false] { + let answer = steady( + p, + input, + 0, + &mut cache, + &mut Budget::new(1_000_000), + first, + anchored, + ); + // A search for the first end reports only that there is one. + let expected = matched.then(|| 1 + width(&hit)); + let answer = + answer.map(|end| end.map(|at| if first { 1 + width(&hit) } else { at })); + assert_eq!( + answer, + Some(expected), + "/{pattern}/{flags} on {points:x?}, first {first}" + ); + } + } + } + } +} diff --git a/src/executor.rs b/src/executor.rs index 05ec488..4b5afb7 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -8,7 +8,7 @@ use crate::{ }; mod admission; mod atom; -mod candidate; +pub(crate) mod candidate; mod state; use state::*; const UNSET: usize = usize::MAX; diff --git a/src/executor/candidate.rs b/src/executor/candidate.rs index da874c0..864d530 100644 --- a/src/executor/candidate.rs +++ b/src/executor/candidate.rs @@ -6,12 +6,12 @@ use super::*; /// candidate phase. A pattern without a leading claim carries none, and builds /// none: for a short subject the description costs more than the scan it steers. #[derive(Clone, Copy)] -struct Prefix { - first: [u8; LEADING_BRANCHES], +pub(crate) struct Prefix { + pub(crate) first: [u8; LEADING_BRANCHES], /// The character after each first one. Every leading run and every branch /// of an admitted alternation holds at least two, so this is always known. - second: [u8; LEADING_BRANCHES], - members: usize, + pub(crate) second: [u8; LEADING_BRANCHES], + pub(crate) members: usize, } /// The longest remainder `short_start` scans for one unit of work. @@ -111,91 +111,6 @@ impl Vm<'_, '_, '_, '_> { Phase::Initialize(0) }; } - /// What the scan should look for, for a program that carries a leading - /// claim. - fn prefix(&self, leading: usize) -> Prefix { - if leading == LEADING_ALTERNATION as usize { - self.branch_pairs() - } else { - self.literal_pairs() - } - } - - /// The first two characters of every branch of the entry alternation. The - /// derivation proved every branch begins with at least two ASCII - /// characters, so each contributes exactly one pair, and there are at most - /// `LEADING_BRANCHES` of them. - fn branch_pairs(&self) -> Prefix { - let mut prefix = Prefix { - first: [0; LEADING_BRANCHES], - second: [0; LEADING_BRANCHES], - members: 0, - }; - let mut pending = [0usize; LEADING_BRANCHES]; - let mut depth = 0; - let mut pc = self.program.leading_pc(); - loop { - if let Some((first, second)) = branch_arms(pc, self.program.instruction(pc)) { - if depth == pending.len() { - prefix.members = 0; - return prefix; - } - pending[depth] = second; - depth += 1; - pc = first; - continue; - } - if prefix.members == prefix.first.len() { - prefix.members = 0; - return prefix; - } - prefix.first[prefix.members] = self.program.instruction(pc)[1] as u8; - prefix.second[prefix.members] = self.program.instruction(pc + 1)[1] as u8; - prefix.members += 1; - if depth == 0 { - return prefix; - } - depth -= 1; - pc = pending[depth]; - } - } - - /// The byte pairs a leading literal admits at its first two characters. A - /// folded run admits either case of each, and `derive_leading` accepts only - /// ASCII characters, so every admitted byte is a single one. This scans - /// wholly ASCII storage, where an ASCII-insensitive comparison is exact for - /// a folded run. - fn literal_pairs(&self) -> Prefix { - let mut prefix = Prefix { - first: [0; LEADING_BRANCHES], - second: [0; LEADING_BRANCHES], - members: 0, - }; - let pc = self.program.leading_pc(); - let one = self.program.instruction(pc)[1] as u8; - let two = self.program.instruction(pc + 1)[1] as u8; - if !self.program.leading_fold() { - prefix.first[0] = one; - prefix.second[0] = two; - prefix.members = 1; - return prefix; - } - for a in [one.to_ascii_lowercase(), one.to_ascii_uppercase()] { - for b in [two.to_ascii_lowercase(), two.to_ascii_uppercase()] { - let held = prefix.first[..prefix.members] - .iter() - .zip(&prefix.second[..prefix.members]) - .any(|(&f, &s)| f == a && s == b); - if !held { - prefix.first[prefix.members] = a; - prefix.second[prefix.members] = b; - prefix.members += 1; - } - } - } - prefix - } - /// Compare the entry alternation's branches against original bytes at `at`, /// succeeding as soon as one matches. The branches are read from the /// instructions, so this needs no program storage and no match state. @@ -324,7 +239,7 @@ impl Vm<'_, '_, '_, '_> { // A leading run and an alternation both name the exact bytes their // first two characters admit, which on ordinary text stops at a small // fraction of the positions the descriptor's widened interval does. - let prefix = (leading != 0).then(|| self.prefix(leading)); + let prefix = (leading != 0).then(|| prefix(self.program, leading)); let mut scanned = 0; let found = loop { let (found, inspected) = match &prefix { @@ -435,6 +350,95 @@ impl Vm<'_, '_, '_, '_> { } } +/// What a start scan should look for, for a program that carries a leading +/// claim of `leading` characters: the byte pairs it admits at its first two. +/// The evaluator's candidate phase and the automaton's idle skip both use it. +#[inline(always)] +pub(crate) fn prefix(program: Program<'_>, leading: usize) -> Prefix { + if leading == LEADING_ALTERNATION as usize { + branch_pairs(program) + } else { + literal_pairs(program) + } +} + +/// The first two characters of every branch of the entry alternation. The +/// derivation proved every branch begins with at least two ASCII +/// characters, so each contributes exactly one pair, and there are at most +/// `LEADING_BRANCHES` of them. +#[inline(always)] +fn branch_pairs(program: Program<'_>) -> Prefix { + let mut prefix = Prefix { + first: [0; LEADING_BRANCHES], + second: [0; LEADING_BRANCHES], + members: 0, + }; + let mut pending = [0usize; LEADING_BRANCHES]; + let mut depth = 0; + let mut pc = program.leading_pc(); + loop { + if let Some((first, second)) = branch_arms(pc, program.instruction(pc)) { + if depth == pending.len() { + prefix.members = 0; + return prefix; + } + pending[depth] = second; + depth += 1; + pc = first; + continue; + } + if prefix.members == prefix.first.len() { + prefix.members = 0; + return prefix; + } + prefix.first[prefix.members] = program.instruction(pc)[1] as u8; + prefix.second[prefix.members] = program.instruction(pc + 1)[1] as u8; + prefix.members += 1; + if depth == 0 { + return prefix; + } + depth -= 1; + pc = pending[depth]; + } +} + +/// The byte pairs a leading literal admits at its first two characters. A +/// folded run admits either case of each, and `derive_leading` accepts only +/// ASCII characters, so every admitted byte is a single one. This scans +/// wholly ASCII storage, where an ASCII-insensitive comparison is exact for +/// a folded run. +#[inline(always)] +fn literal_pairs(program: Program<'_>) -> Prefix { + let mut prefix = Prefix { + first: [0; LEADING_BRANCHES], + second: [0; LEADING_BRANCHES], + members: 0, + }; + let pc = program.leading_pc(); + let one = program.instruction(pc)[1] as u8; + let two = program.instruction(pc + 1)[1] as u8; + if !program.leading_fold() { + prefix.first[0] = one; + prefix.second[0] = two; + prefix.members = 1; + return prefix; + } + for a in [one.to_ascii_lowercase(), one.to_ascii_uppercase()] { + for b in [two.to_ascii_lowercase(), two.to_ascii_uppercase()] { + let held = prefix.first[..prefix.members] + .iter() + .zip(&prefix.second[..prefix.members]) + .any(|(&f, &s)| f == a && s == b); + if !held { + prefix.first[prefix.members] = a; + prefix.second[prefix.members] = b; + prefix.members += 1; + } + } + } + prefix +} + /// The first position holding any byte of `set`, and the positions inspected. /// /// A union of first characters is widened into one interval for the word 7 @@ -450,7 +454,7 @@ impl Vm<'_, '_, '_, '_> { /// ordinary text, and a folded first character far more than that. Deciding it /// on two admits almost none, which is what the positions this admits cost: /// each is published as a start, or compared against the whole prefix. -pub(super) fn first_in_pairs( +pub(crate) fn first_in_pairs( bytes: &[u8], limit: usize, first: &[u8], @@ -521,7 +525,9 @@ pub(super) fn first_in_pairs( (None, limit) } -pub(super) fn first_in_range( +/// The first position whose byte lies in `lo..=hi` (or, with +/// `STOP_NON_ASCII`, is not ASCII), and the positions inspected. +pub(crate) fn first_in_range( bytes: &[u8], lo: u8, hi: u8, @@ -530,27 +536,10 @@ pub(super) fn first_in_range( return (Some(0), 1); } let mut i = 1; - const HIGH: u64 = 0x8080_8080_8080_8080; - const ONES: u64 = 0x0101_0101_0101_0101; - let lower = u64::from(lo) * ONES; - let upper = u64::from(hi) * ONES; + let (lower, upper) = (u64::from(lo) * ONES, u64::from(hi) * ONES); while i + 8 <= bytes.len() { let word = u64::from_le_bytes(bytes[i..i + 8].try_into().unwrap()); - let lanes = if MIXED { word & !HIGH } else { word }; - // ASCII lanes are <=127. Setting the minuend's high bit prevents - // inter-lane borrows in both subtractions, so every lane is exact. - let range = - ((lanes | HIGH).wrapping_sub(lower)) & ((upper | HIGH).wrapping_sub(lanes)) & HIGH; - let mask = if STOP_NON_ASCII { - range | (word & HIGH) - } else if MIXED { - // Admission seeks an ASCII member, so UTF-8/WTF-8 high-byte lanes - // cannot qualify. Masking them before subtraction prevents borrows - // from changing an adjacent lane's comparison. - range & !word - } else { - range - }; + let mask = range_marks::(word, lower, upper); if mask != 0 { let at = i + mask.trailing_zeros() as usize / 8; return (Some(at), at + 1); @@ -566,6 +555,87 @@ pub(super) fn first_in_range( (None, bytes.len()) } +const HIGH: u64 = 0x8080_8080_8080_8080; +const ONES: u64 = 0x0101_0101_0101_0101; + +/// The high bit of every lane of `word` that [`first_in_range`] stops at, +/// for `lower` and `upper` holding `lo` and `hi` in every lane. +#[inline(always)] +pub(crate) fn range_marks( + word: u64, + lower: u64, + upper: u64, +) -> u64 { + let lanes = if MIXED { word & !HIGH } else { word }; + // ASCII lanes are <=127. Setting the minuend's high bit prevents + // inter-lane borrows in both subtractions, so every lane is exact. + let range = ((lanes | HIGH).wrapping_sub(lower)) & ((upper | HIGH).wrapping_sub(lanes)) & HIGH; + if STOP_NON_ASCII { + range | (word & HIGH) + } else if MIXED { + // Admission seeks an ASCII member, so UTF-8/WTF-8 high-byte lanes + // cannot qualify. Masking them before subtraction prevents borrows + // from changing an adjacent lane's comparison. + range & !word + } else { + range + } +} + +/// [`first_in_range`] for a caller that scans between short steps, such as +/// the automaton's idle skip, where a call would cost more than a short scan. +/// After the first word it tests four words at a time with one branch, so a +/// long run without a stop costs one branch per thirty-two bytes. +#[inline(always)] +pub(crate) fn scan_range( + bytes: &[u8], + lo: u8, + hi: u8, +) -> Option { + if (bytes[0] >= lo && bytes[0] <= hi) || (STOP_NON_ASCII && bytes[0] >= 128) { + return Some(0); + } + let mut i = 1; + let (lower, upper) = (u64::from(lo) * ONES, u64::from(hi) * ONES); + let marks = |word: &[u8; 8]| { + range_marks::(u64::from_le_bytes(*word), lower, upper) + }; + // One word first: where stops are dense, the next one is usually in it. + if let Some(word) = bytes.get(i..i + 8) { + let mask = marks(word.try_into().unwrap()); + if mask != 0 { + return Some(i + mask.trailing_zeros() as usize / 8); + } + i += 8; + } + while let Some(block) = bytes.get(i..i + 32) { + let (words, _) = block.as_chunks::<8>(); + let found = [ + marks(&words[0]), + marks(&words[1]), + marks(&words[2]), + marks(&words[3]), + ]; + if found[0] | found[1] | found[2] | found[3] != 0 { + for (word, &mask) in found.iter().enumerate() { + if mask != 0 { + return Some(i + 8 * word + mask.trailing_zeros() as usize / 8); + } + } + } + i += 32; + } + while let Some(word) = bytes.get(i..i + 8) { + let mask = marks(word.try_into().unwrap()); + if mask != 0 { + return Some(i + mask.trailing_zeros() as usize / 8); + } + i += 8; + } + (i..bytes.len()) + .find(|&at| (bytes[at] >= lo && bytes[at] <= hi) || (STOP_NON_ASCII && bytes[at] >= 128)) +} + #[cfg(test)] mod tests { use super::first_in_range; diff --git a/src/lib.rs b/src/lib.rs index 463e048..cfda95f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod span; mod casefold; pub mod compiler; +pub mod dfa; pub mod executor; pub mod native; pub mod program; diff --git a/src/program.rs b/src/program.rs index a9b2895..fb221a5 100644 --- a/src/program.rs +++ b/src/program.rs @@ -3,7 +3,7 @@ use crate::{Budget, properties}; pub(crate) const HEADER: usize = 11; pub(crate) const MAGIC: u32 = 0x50525831; -pub(crate) const VERSION: u32 = 14; +pub(crate) const VERSION: u32 = 15; pub(crate) const U: u32 = 1; pub(crate) const M: u32 = 2; pub(crate) const S: u32 = 4; @@ -100,6 +100,13 @@ pub(crate) const BRANCH: u32 = 31; /// [`derive_start_anchored`] and re-derived on validation, so a search reads /// it instead of walking the entry each time. pub(crate) const ANCHORED: u32 = 1 << 24; +/// Word 7 flag: every instruction is one the lazy automaton decides exactly +/// and every repeat's counter fits its state key. Derived by [`derive_dfa`] +/// and re-derived on validation; see `docs/dfa.md`. +pub(crate) const DFA: u32 = 1 << 25; +/// Largest repeat bound the automaton counts to: a maximum, or the minimum of +/// an unbounded repeat. +pub(crate) const DFA_COUNT_MAX: u32 = 1023; /// Word 7 bits holding the first-character descriptor. pub(crate) const DESCRIPTOR: u32 = 0xff_ffff; /// A first-character mask admitting every character: the set is unknown. @@ -478,6 +485,85 @@ pub(crate) fn leading_pc(words: &[u32], instructions: usize) -> usize { pc } +/// Whether the lazy automaton can decide this program exactly. Every +/// instruction must be a character, class, `.`, capture bookkeeping, branch, +/// jump, position assertion or repeat, and every repeat must be in the shape +/// the compiler emits with a counter [`dfa_fields`] can place. Backreferences, +/// lookaround and properties of strings make it ineligible. +/// +/// Called on validated instructions and repeat records only. +pub(crate) fn derive_dfa(p: Program<'_>) -> bool { + if p.instructions() > PC_LIMIT { + return false; + } + for pc in 0..p.instructions() { + let [op, a, _] = p.instruction(pc); + let admitted = match op { + MATCH | CHAR | CHAR_I | ANY | ANY_S | CLASS | CLASS_I | CLASS_SORTED + | CLASS_SORTED_I | SAVE | SPLIT | BRANCH | JUMP | START | START_M | END | END_M + | WORD | WORD_I | REPEAT_INIT | ATOM_REPEAT => true, + REPEAT_CHOICE => p.repeat(a as usize)[3] as usize == pc + 1, + REPEAT_BODY => p.repeat(a as usize)[3] as usize == pc, + REPEAT_NEXT => p.repeat(a as usize)[4] as usize == pc + 1, + _ => false, + }; + if !admitted { + return false; + } + } + dfa_fields(p, |_, _, _| {}) +} + +/// Instructions an automaton thread can address beside its generation tag. +const PC_LIMIT: usize = (1 << 24) - 1; + +/// Place each repeat's counter in a 32-bit key: the bits of its bound and one +/// empty-check bit above them, after the fields of the repeats around it. +/// Repeats that do not enclose one another share bits, since a thread is +/// inside at most one of them. Reports each repeat as `(id, offset, bits)` and +/// returns false when a repeat is not in the emitted shape, does not nest +/// properly, counts too far, or the fields around one instruction exceed 32 +/// bits. +pub(crate) fn dfa_fields(p: Program<'_>, mut each: impl FnMut(usize, u32, u32)) -> bool { + let n = p.instructions(); + let mut open = [(0usize, 0u32); 32]; + let mut depth = 0; + for pc in 0..n { + while depth > 0 && open[depth - 1].0 <= pc { + depth -= 1; + } + let [op, a, _] = p.instruction(pc); + if !matches!(op, REPEAT_INIT | ATOM_REPEAT) { + continue; + } + let r = p.repeat(a as usize); + let exit = r[4] as usize; + if r[3] as usize != pc + 2 + || exit < pc + 4 + || p.instruction(pc + 1) != [REPEAT_CHOICE, a, 0] + || p.instruction(pc + 2) != [REPEAT_BODY, a, 0] + || p.instruction(exit - 1) != [REPEAT_NEXT, a, 0] + || (depth > 0 && exit > open[depth - 1].0) + || depth == open.len() + { + return false; + } + let bound = if r[2] & 1 != 0 { r[0] } else { r[1] }; + if bound > DFA_COUNT_MAX { + return false; + } + let bits = u32::BITS - bound.leading_zeros(); + let offset = if depth > 0 { open[depth - 1].1 } else { 0 }; + if offset + bits + 1 > 32 { + return false; + } + each(a as usize, offset, bits); + open[depth] = (exit, offset + bits + 1); + depth += 1; + } + true +} + /// Whether a failed attempt at one start proves failure at every later start /// inside the same run of the pattern's leading atom. /// @@ -546,7 +632,7 @@ impl<'a> Program<'a> { // semantic guarantee belongs to the compiler. for (index, &candidate) in words[7..9].iter().enumerate() { let descriptor = candidate & DESCRIPTOR; - if index == 0 && candidate & !(DESCRIPTOR | ANCHORED) != 0 { + if index == 0 && candidate & !(DESCRIPTOR | ANCHORED | DFA) != 0 { return Err(bad); } let lo = (descriptor >> 8) & 255; @@ -778,6 +864,12 @@ impl<'a> Program<'a> { } } } + budget + .charge(p.instructions() / 8 + 1) + .map_err(|_| ProgramError::WorkLimit)?; + if (words[7] & DFA != 0) != derive_dfa(p) { + return Err(bad); + } Ok(p) } pub fn words(self) -> &'a [u32] { diff --git a/tests/admission.rs b/tests/admission.rs index 40d3973..b339ac1 100644 --- a/tests/admission.rs +++ b/tests/admission.rs @@ -21,7 +21,8 @@ fn compile_words(source: &str, flags: &str) -> Vec { .unwrap(); let mut moved = p.words().to_vec(); // These witnesses isolate required-text admission from candidate skipping. - moved[7] = 0; + // The descriptor only: bits 24 and 25 are validated claims. + moved[7] &= !0x00ff_ffff; words.fill(0xdeadbeef); moved } diff --git a/tests/atom_repeat.rs b/tests/atom_repeat.rs index fcfa149..78951bc 100644 --- a/tests/atom_repeat.rs +++ b/tests/atom_repeat.rs @@ -213,7 +213,7 @@ fn every_insufficient_work_allowance_preserves_output() { #[test] fn optimized_record_validation_rejects_shape_and_version_corruption() { let w = words("^([a-z]+)$", ""); - assert_eq!(w[1], 14); + assert_eq!(w[1], 15); let table = 11 + w[4] as usize * 3 + w[5] as usize * 2; assert_eq!(w[table + 7], 1); let entry = w[table + 3] as usize - 2; diff --git a/tests/branch.rs b/tests/branch.rs index 15ad550..2fcb314 100644 --- a/tests/branch.rs +++ b/tests/branch.rs @@ -337,7 +337,7 @@ fn validation_rederives_masks_and_the_anchored_claim() { "anchored {flip}" ); let mut reserved = program.clone(); - reserved[7] |= 1 << 25; + reserved[7] |= 1 << 26; assert!(Program::from_words(&reserved, &mut Budget::new(100_000)).is_err()); } } diff --git a/tests/candidate.rs b/tests/candidate.rs index 6bed81c..1c47901 100644 --- a/tests/candidate.rs +++ b/tests/candidate.rs @@ -78,7 +78,8 @@ fn candidate_ranges_preserve_nullable_prefixes_captures_assertions_and_case() { ] { let enabled = words(source, flags); let mut disabled = enabled.clone(); - disabled[7] = 0; + // Clear the descriptor only; bits 24 and 25 are validated claims. + disabled[7] &= !0x00ff_ffff; for tail in [ "", "needle", "aaab", "bc", "KS", "ks", "bba", "\nAa", "😀b", "ſK", ] { @@ -115,7 +116,7 @@ fn candidate_ranges_preserve_nullable_prefixes_captures_assertions_and_case() { fn candidate_skipping_is_required_for_the_bounded_long_miss() { let enabled = words("needle", ""); let mut disabled = enabled.clone(); - disabled[7] = 0; + disabled[7] &= !0x00ff_ffff; let text = "x".repeat(4096); assert_eq!(run(&enabled, Input::utf8(&text), 0, 4200), Ok(None)); assert_eq!( @@ -127,7 +128,7 @@ fn candidate_skipping_is_required_for_the_bounded_long_miss() { Err(ExecError::WorkLimit) ); let impossible = words("[^\\x00-\\x7f]", ""); - assert_eq!(impossible[7], 1); + assert_eq!(impossible[7] & 0x00ff_ffff, 1); assert_eq!(run(&impossible, Input::utf8(&text), 0, 2), Ok(None)); } #[test] @@ -303,7 +304,8 @@ fn mixed_subject_starts_preserve_half_pairs_and_utf16_capture_offsets() { ] { let enabled = words(source, flags); let mut disabled = enabled.clone(); - disabled[7] = 0; + // Clear the descriptor only; bits 24 and 25 are validated claims. + disabled[7] &= !0x00ff_ffff; for prefix in ["é", "😀", "𐀀", "K"] { for size in [0, 7, 8, 9, 255, 256, 257] { let subject = format!("{prefix}{}needleneedle{prefix}", "x".repeat(size)); @@ -325,7 +327,7 @@ fn mixed_subject_starts_preserve_half_pairs_and_utf16_capture_offsets() { } let enabled = words("needle", ""); let mut disabled = enabled.clone(); - disabled[7] = 0; + disabled[7] &= !0x00ff_ffff; let text = format!("é{}", "x".repeat(4096)); assert_eq!(run(&enabled, Input::utf8(&text), 0, 4300), Ok(None)); assert_eq!( diff --git a/tests/classes.rs b/tests/classes.rs index c4e54b7..28189f1 100644 --- a/tests/classes.rs +++ b/tests/classes.rs @@ -57,7 +57,7 @@ fn members() -> String { fn sorted_classes_preserve_original_membership_and_reject_invalid_order() { let source = format!("[{}]", members()); let words = program(&source, "u"); - assert_eq!(words[1], 14); + assert_eq!(words[1], 15); assert_eq!(words[11 + 3], 27); // Sorted class following capture-zero start. for unit in 0..0x400u16 { let expected = (0..128).any(|i| unit == 0x100 + i * 3); diff --git a/tests/dfa.rs b/tests/dfa.rs new file mode 100644 index 0000000..a08b519 --- /dev/null +++ b/tests/dfa.rs @@ -0,0 +1,555 @@ +//! The lazy automaton: eligibility, answers against the evaluator, the cache's +//! cap, clearing, thrashing and relocation. See `docs/dfa.md`. +use perex::{ + Budget, + compiler::{Node, Range, compile}, + dfa::{self, Decline, Found, Tested}, + executor::{Frame, Scratch, Undo, find, is_match}, + input::Input, + program::Program, + span::Span, +}; + +fn program(pattern: &str, flags: &str) -> Vec { + let source = Input::utf8(pattern); + let mut nodes = vec![Node::default(); source.len_utf16() * 4 + 64]; + let mut ranges = vec![Range::default(); source.len_utf16() * 12 + 64]; + let mut words = vec![0; source.len_utf16() * 48 + 256]; + let p = compile( + source, + flags, + &mut nodes, + &mut ranges, + &mut words, + &mut Budget::new(10_000_000), + ) + .unwrap_or_else(|e| panic!("/{pattern}/{flags}: {e:?}")); + p.words().to_vec() +} + +fn view(words: &[u32]) -> Program<'_> { + Program::from_words(words, &mut Budget::new(10_000_000)).unwrap() +} + +/// The evaluator's answer: group zero. +fn evaluator(words: &[u32], subject: &[u8], start: usize) -> Option { + let p = view(words); + let input = Input::wtf8(subject).unwrap(); + let mut registers = vec![0; p.register_count()]; + let mut frames = vec![Frame::default(); 4096]; + let mut undo = vec![Undo::default(); 65536]; + let mut captures = vec![None; p.capture_count()]; + let found = find( + p, + input, + start, + Scratch { + registers: &mut registers, + frames: &mut frames, + undo: &mut undo, + }, + &mut captures, + &mut Budget::new(50_000_000), + ) + .unwrap(); + let tested = is_match( + p, + input, + start, + Scratch { + registers: &mut registers, + frames: &mut frames, + undo: &mut undo, + }, + &mut Budget::new(50_000_000), + ) + .unwrap(); + assert_eq!(found, tested); + found.then(|| captures[0].unwrap()) +} + +/// The automaton's answers, both of them, checked against each other. +fn automaton(words: &[u32], subject: &[u8], start: usize, cache: &mut [u32]) -> Option { + let p = view(words); + let input = Input::wtf8(subject).unwrap(); + let found = dfa::find(p, input, start, cache, &mut Budget::new(50_000_000)).unwrap(); + let tested = dfa::is_match(p, input, start, cache, &mut Budget::new(50_000_000)).unwrap(); + match (found, tested) { + (Found::Match(span), Tested::Match) => Some(span), + (Found::NoMatch, Tested::NoMatch) => None, + other => panic!("{other:?}"), + } +} + +fn agree(pattern: &str, flags: &str, subjects: &[&str]) { + let words = program(pattern, flags); + assert!(dfa::eligible(view(&words)), "/{pattern}/{flags} ineligible"); + let mut cache = vec![0u32; 1 << 16]; + for subject in subjects { + let len = subject.encode_utf16().count(); + for start in 0..=len + 1 { + let expected = evaluator(&words, subject.as_bytes(), start); + let actual = automaton(&words, subject.as_bytes(), start, &mut cache); + assert_eq!( + actual, expected, + "/{pattern}/{flags} on {subject:?} from {start}" + ); + } + } +} + +const SUBJECTS: &[&str] = &[ + "", + "a", + "ab", + "abc abd", + "aaa", + "xaby ab", + "AbC", + "a\nb\r\nc", + "foo bar_baz 12", + "\u{2028}x\u{2029}", + "é ß ſ K k", + "😀a😀", + "\u{1F600}\u{1F601}", + "ababababa", + "cdcdgh", + " x ", +]; + +#[test] +fn literals_classes_and_alternation() { + for (pattern, flags) in [ + ("a", ""), + ("ab|cd|ef|gh", ""), + ("[a-c]+", ""), + ("[^a]", ""), + ("a.c", ""), + ("a.c", "s"), + ("(a|ab)(c|bcd)", ""), + ("a|ab", ""), + ("ab|a", ""), + ("x*", ""), + ("x*?", ""), + ("(?:ab)*", ""), + ("(?:a|b)*?b", ""), + ("[😀-😁]", "u"), + (".", "u"), + (".", ""), + ("\\p{L}+", "u"), + ("\\P{L}", "u"), + ("\\p{L}[a-z]", "v"), + ("k", "iu"), + ("[k-l]", "iu"), + ("ß", "i"), + ("[a-z]+", "i"), + ("\\w+", "iu"), + ("\\p{Lu}", "iu"), + ("\\p{Lu}+", "i"), + ("[^\\p{Ll}]", "iu"), + ("\\P{Ll}", "iu"), + ("[\\p{Lu}\\d]", "iu"), + ] { + agree(pattern, flags, SUBJECTS); + } +} + +#[test] +fn assertions() { + for (pattern, flags) in [ + ("^a", ""), + ("^a", "m"), + ("b$", ""), + ("b$", "m"), + ("^$", "m"), + ("\\b", ""), + ("\\B", ""), + ("\\ba\\w*\\b", ""), + ("\\bk", "iu"), + ("\\b\\w", "iu"), + ("(?:^|\\s)x", ""), + ("$|a", ""), + ("^\\p{Default_Ignorable_Code_Point}$", "u"), + ] { + agree(pattern, flags, SUBJECTS); + } +} + +#[test] +fn repeats_count_and_check_empty_iterations() { + for (pattern, flags) in [ + ("a{2}", ""), + ("a{2,3}", ""), + ("a{2,}", ""), + ("a{0,2}?b", ""), + ("(?:a|b){2,4}", ""), + ("(?:ab|a){1,3}?", ""), + ("(a*)*b", ""), + ("(a*)+", ""), + ("(?:a?)*", ""), + ("(?:a?){2}", ""), + ("(?:a?){2,}?", ""), + ("(?:\\b|a)*", ""), + ("(?:^)*a", ""), + ("(?:$|a)+", ""), + ("(?:(?:a|)b?)*c", ""), + ("(?:a{0,2}){2}x", ""), + ("((?:a|b)*?)(?:c|d)", ""), + ("(?:x?)*?y", ""), + ] { + agree(pattern, flags, SUBJECTS); + } +} + +#[test] +fn sticky_and_anchored_search_one_start() { + for (pattern, flags) in [("a", "y"), ("b|ab", "y"), ("^a", ""), (".", "uy")] { + agree(pattern, flags, SUBJECTS); + } +} + +#[test] +fn ineligible_programs_are_declined() { + for (pattern, flags) in [ + ("(a)\\1", ""), + ("a(?=b)", ""), + ("(?<=a)b", ""), + ("\\p{RGI_Emoji}", "v"), + ("a{2000}", ""), + ] { + let words = program(pattern, flags); + let p = view(&words); + assert!(!dfa::eligible(p), "/{pattern}/{flags}"); + assert_eq!(dfa::minimum_words(p), None); + let mut cache = vec![0; 1 << 12]; + assert_eq!( + dfa::is_match(p, Input::utf8("ab"), 0, &mut cache, &mut Budget::new(1000)), + Ok(Tested::Declined(Decline::Ineligible)) + ); + } +} + +#[test] +fn eligibility_is_validated_both_ways() { + let mut words = program("ab|c", ""); + assert_ne!(words[7] & (1 << 25), 0); + words[7] &= !(1 << 25); + assert!(Program::from_words(&words, &mut Budget::new(1_000_000)).is_err()); + let mut words = program("(a)\\1", ""); + words[7] |= 1 << 25; + assert!(Program::from_words(&words, &mut Budget::new(1_000_000)).is_err()); +} + +#[test] +fn storage_below_the_minimum_is_declined() { + let words = program("ab|cd", ""); + let p = view(&words); + let minimum = dfa::minimum_words(p).unwrap(); + let mut small = vec![0; minimum - 1]; + assert_eq!( + dfa::is_match(p, Input::utf8("cd"), 0, &mut small, &mut Budget::new(1000)), + Ok(Tested::Declined(Decline::Storage { + required_words: minimum + })) + ); + let mut exact = vec![0; minimum]; + assert_eq!( + dfa::find(p, Input::utf8("xcd"), 0, &mut exact, &mut Budget::new(1000)), + Ok(Found::Match(Span::new(1, 3).unwrap())) + ); +} + +/// A pattern whose states are the last nine characters seen: 512 states over +/// a random subject, far more than a minimum cache holds. +fn many_states() -> Vec { + program("a[ab]{8}c", "") +} + +fn random_ab(len: usize, seed: u64) -> String { + let mut x = seed; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + match x % 3 { + 0 => 'a', + 1 => 'b', + _ => 'c', + } + }) + .collect() +} + +#[test] +fn a_full_cache_clears_and_answers_stay_exact() { + let words = many_states(); + let p = view(&words); + let mut cache = vec![0; dfa::minimum_words(p).unwrap() + 2048]; + for seed in 1..40 { + let subject = random_ab(400, seed); + let expected = evaluator(&words, subject.as_bytes(), 0); + let found = dfa::find( + p, + Input::utf8(&subject), + 0, + &mut cache, + &mut Budget::new(1 << 30), + ) + .unwrap(); + match found { + Found::Match(span) => assert_eq!(Some(span), expected), + Found::NoMatch => assert_eq!(None, expected), + Found::Declined(Decline::Disabled) => break, + other => panic!("{other:?}"), + } + } + let stats = dfa::stats(&cache).unwrap(); + assert!(stats.clears > 0, "{stats:?}"); + assert!(stats.peak_words <= cache.len()); +} + +#[test] +fn thrashing_disables_the_cache_until_reset() { + let words = many_states(); + let p = view(&words); + let minimum = dfa::minimum_words(p).unwrap(); + assert!(!dfa::stats(&[0; 4]).is_some_and(|s| s.disabled)); + let mut cache = vec![0; minimum]; + let subject = random_ab(20_000, 7).replace('c', "a"); + let mut disabled = false; + for _ in 0..50 { + match dfa::is_match( + p, + Input::utf8(&subject), + 0, + &mut cache, + &mut Budget::new(1 << 30), + ) + .unwrap() + { + Tested::Declined(Decline::Disabled) => { + disabled = true; + break; + } + Tested::Match | Tested::NoMatch => {} + other => panic!("{other:?}"), + } + } + assert!(disabled, "{:?}", dfa::stats(&cache)); + assert!(dfa::stats(&cache).unwrap().disabled); + dfa::reset(&mut cache); + assert!(dfa::stats(&cache).is_none()); + assert!(!matches!( + dfa::is_match( + p, + Input::utf8("abbbbbbbbc"), + 0, + &mut cache, + &mut Budget::new(1 << 20) + ), + Ok(Tested::Declined(_)) + )); +} + +#[test] +fn the_cache_moves_between_calls() { + let words = program("(?:\\bfoo|bar)+\\d{1,3}", "i"); + let mut cache = vec![0; 1 << 14]; + for (i, subject) in ["x FOObar12 y", "barfoo999", "foo", "nope bar1"] + .iter() + .enumerate() + { + let expected = evaluator(&words, subject.as_bytes(), 0); + let actual = automaton(&words, subject.as_bytes(), 0, &mut cache); + assert_eq!(actual, expected, "{subject}"); + let moved = cache.clone(); + cache.fill(0xdead_beef); + cache = moved; + if i == 2 { + // A cache built for a different program is rebuilt, not reused. + let other = program("x", ""); + assert_eq!( + dfa::find( + view(&other), + Input::utf8("ax"), + 0, + &mut cache, + &mut Budget::new(1000) + ), + Ok(Found::Match(Span::new(1, 2).unwrap())) + ); + } + } +} + +/// Long subjects, searched again and again from several starts, so the +/// steady path runs with its skip to a byte a match can begin with. +#[test] +fn long_subjects_on_the_steady_path() { + let text = "the quick brown fox jumps over the lazy dog 0123 456-7890 café naïve xyz "; + let ascii: String = text.chars().filter(char::is_ascii).collect(); + let subjects = [ + ascii.repeat(4), + text.repeat(3), + format!("{}zq", "a".repeat(100)), + format!("{}qz{}", "y".repeat(70), "x".repeat(40)), + format!("{}😀Q", "0123456789 ".repeat(8)), + format!("{}é😀Q", "0123456789 ".repeat(8)), + // A match after two-, three- and four-byte characters, whose + // position is counted only when the match is found. + format!("😀é中{}😀 555-0199 ka", "the quick brown fox ".repeat(3)), + // Folded letters outside ASCII after a long run. + format!("{}ſK \u{212A}A KA a\u{212A}", "lorem ipsum ".repeat(6)), + // Characters outside ASCII that a class admits, after a long run. + format!("{}é5 ü7 z9", "lorem ipsum ".repeat(6)), + ]; + for (pattern, flags) in [ + ("zq|qz|xj", ""), + ("[x-z]", ""), + ("\\d{3}-\\d{4}", ""), + ("a[bc]|dog", ""), + ("o\\w*", ""), + ("é|z", "u"), + ("[q-z]{2}", "i"), + // The descriptor admits A-F; 😀 can begin a match too. + ("(?:((?:😀|[A-F])))+Q", ""), + ("(?:((?:😀|[A-F])))+Q", "u"), + ("(?:é|[x-z])+", ""), + // A leading pair over storage that is not ASCII: exact bytes, and a + // folded run whose `k` also matches U+212A. + ("ka|ab", ""), + ("ka", "iu"), + ("sk", "iu"), + ("ak", "iu"), + // A first character that may be non-ASCII keeps the skip from + // passing over non-ASCII characters. + ("[^a-z ]\\d", ""), + ("[à-ÿ]\\d", ""), + ("[x-zé]\\d", ""), + ("\\p{L}\\d", "u"), + ] { + let words = program(pattern, flags); + let mut cache = vec![0u32; 1 << 15]; + for subject in &subjects { + let len = subject.encode_utf16().count(); + for start in [0, 1, 7, 33, len / 2, len] { + for _ in 0..2 { + let expected = evaluator(&words, subject.as_bytes(), start); + let actual = automaton(&words, subject.as_bytes(), start, &mut cache); + assert_eq!(actual, expected, "/{pattern}/{flags} from {start}"); + } + } + } + } +} + +#[test] +fn an_exhausted_budget_is_an_error_not_an_answer() { + let words = program("z", ""); + let p = view(&words); + let mut cache = vec![0; 1 << 12]; + let subject = "a".repeat(1000); + assert_eq!( + dfa::is_match( + p, + Input::utf8(&subject), + 0, + &mut cache, + &mut Budget::new(100) + ), + Err(perex::executor::ExecError::WorkLimit) + ); +} + +/// Random eligible patterns against the evaluator, over byte, ASCII and +/// surrogate-bearing subjects, from every start. +#[test] +fn random_patterns_agree_with_the_evaluator() { + let atoms = [ + "a", "b", "c", ".", "[ab]", "[^b]", "\\w", "\\W", "\\b", "\\B", "^", "$", "\\s", "\\d", + "k", "[a-z]", "😀", "\\n", + ]; + let quantifiers = [ + "", "", "", "*", "+", "?", "*?", "+?", "??", "{2}", "{1,2}", "{0,3}?", "{2,}", + ]; + let flag_sets = ["", "i", "m", "s", "u", "iu", "mu", "y", "my"]; + let subjects = [ + "", + "abc", + "aab bca\ncab", + "Kk ſ 1_2", + "😀a😀b\u{2028}", + "bbbbaaaa", + ]; + let mut x: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = |n: usize| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x % n as u64) as usize + }; + let mut cache = vec![0u32; 1 << 15]; + for _ in 0..1500 { + let mut pattern = String::new(); + let alternatives = 1 + next(3); + for alternative in 0..alternatives { + if alternative > 0 { + pattern.push('|'); + } + for _ in 0..1 + next(4) { + if next(5) == 0 { + pattern.push_str("(?:"); + pattern.push_str(atoms[next(atoms.len())]); + pattern.push('|'); + pattern.push_str(atoms[next(atoms.len())]); + pattern.push_str(atoms[next(atoms.len())]); + pattern.push(')'); + } else { + pattern.push_str(atoms[next(atoms.len())]); + } + let q = quantifiers[next(quantifiers.len())]; + // `^`, `$` and `\b` take quantifiers only as groups. + if !q.is_empty() + && (pattern.ends_with(['^', '$']) + || pattern.ends_with("\\b") + || pattern.ends_with("\\B")) + { + continue; + } + pattern.push_str(q); + } + } + let flags = flag_sets[next(flag_sets.len())]; + let source = Input::utf8(&pattern); + let mut nodes = vec![Node::default(); source.len_utf16() * 4 + 64]; + let mut ranges = vec![Range::default(); source.len_utf16() * 12 + 64]; + let mut words = vec![0; source.len_utf16() * 48 + 256]; + let Ok(p) = compile( + source, + flags, + &mut nodes, + &mut ranges, + &mut words, + &mut Budget::new(1 << 24), + ) else { + continue; + }; + let words = p.words().to_vec(); + if !dfa::eligible(view(&words)) { + continue; + } + dfa::reset(&mut cache); + for subject in subjects { + let len = subject.encode_utf16().count(); + for start in 0..=len { + let expected = evaluator(&words, subject.as_bytes(), start); + let actual = automaton(&words, subject.as_bytes(), start, &mut cache); + assert_eq!( + actual, expected, + "/{pattern}/{flags} on {subject:?} from {start}" + ); + } + } + } +} diff --git a/tests/modifiers.rs b/tests/modifiers.rs index 682cbb0..8f0b75e 100644 --- a/tests/modifiers.rs +++ b/tests/modifiers.rs @@ -146,7 +146,7 @@ fn modifier_errors_and_instruction_corruption_are_explicit() { (r"(?a)|(?b)\k", "i"), ] { let words = program(source, flags).unwrap(); - assert_eq!(words[1], 14); + assert_eq!(words[1], 15); for pc in 0..words[4] as usize { let at = 11 + pc * 3; if (19..=26).contains(&words[at]) { diff --git a/tools/check-dfa-fuzz.mjs b/tools/check-dfa-fuzz.mjs new file mode 100644 index 0000000..b41a0dd --- /dev/null +++ b/tools/check-dfa-fuzz.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// Random patterns the lazy automaton accepts, against Node. The probe runs +// with PEREX_DFA set (default `1`), so every eligible case is answered by the +// automaton as well and any disagreement with the evaluator is an outcome of +// its own; the answers are compared with V8's. See docs/dfa.md. +// +// usage: check-dfa-fuzz.mjs PROBE OUTPUT_DIR [--seed N] [--cases N] +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { boundedAnswers, parseAnswers, stableDifferences } from './reference.mjs'; + +const args = process.argv.slice(2); +const probe = args.shift(); +const output = args.shift(); +assert(probe && output, 'usage: check-dfa-fuzz.mjs PROBE OUTPUT_DIR [--seed N] [--cases N]'); +const option = (name, fallback) => { + const at = args.indexOf(name); + return at < 0 ? fallback : Number(args[at + 1]); +}; +const seed = option('--seed', 1); +const total = option('--cases', 20000); + +let state = seed >>> 0 || 1; +function random(n) { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return (((t ^ (t >>> 14)) >>> 0) % n); +} +const pick = list => list[random(list.length)]; + +// Characters chosen for the corners: case pairs, the two non-ASCII letters that +// fold into ASCII under `u`, line terminators, word boundaries, a pair and its +// halves, and the start of a property. +const characters = ['a', 'b', 'c', 'A', 'B', 'k', 'K', 's', 'S', 'ſ', 'K', 'é', 'É', 'ß', + '_', '0', '9', ' ', '\n', '\r', '
', '😀', '😂', '\ud83d', '\ude00', '­', '​', 'Ω', 'ω']; +const literal = ['a', 'b', 'c', 'A', 'k', 's', 'é', 'ß', '_', '0', ' ', '\\n', '\\u017f', '\\u212a', + '😀', '\\ud83d', '\\ude00', 'ω', '\\.', '\\u{1F600}']; +const classes = ['.', '\\w', '\\W', '\\d', '\\D', '\\s', '\\S', '[ab]', '[^a-c]', '[a-zé]', '[\\w-]', + '[k-s]', '[A-Z]', '[^\\s]', '[😀-😂]', '[\\ud83d\\ude00]', '[^😀]', '[\\u017f\\u212a]', '[ω-ϊ]']; +const unicodeOnly = ['\\p{L}', '\\p{Lu}', '\\P{Ll}', '\\p{Emoji}', '\\p{Default_Ignorable_Code_Point}', + '\\p{Script=Greek}', '[\\p{Lu}\\d]', '[^\\p{L}]', '\\P{Any}']; +const assertions = ['^', '$', '\\b', '\\B']; +const quantifiers = ['*', '+', '?', '*?', '+?', '??', '{2}', '{1,3}', '{0,2}?', '{2,}', '{0}', '{3,4}?']; + +function atom(depth, unicode) { + const roll = random(20); + if (roll < 6) return pick(literal); + if (roll < 11) return pick(classes); + if (roll < 13) return unicode ? pick(unicodeOnly) : pick(classes); + if (roll < 15) return pick(assertions); + if (depth < 3) { + const open = pick(['(', '(?:', '(?:', '(?']); + return open + alternation(depth + 1, unicode) + ')'; + } + return pick(literal); +} + +function sequence(depth, unicode) { + let text = ''; + for (let i = random(4) + (depth ? 0 : 1); i > 0; i--) { + const a = atom(depth, unicode); + text += a; + if (!assertions.includes(a) && random(3) === 0) text += pick(quantifiers); + } + return text; +} + +function alternation(depth, unicode) { + const parts = [sequence(depth, unicode)]; + while (random(3) === 0) parts.push(sequence(depth, unicode)); + return parts.join('|'); +} + +// Most subjects are short; one in ten is long enough for the steady path's +// skip to a byte a match can begin with. +function subject() { + let text = ''; + for (let i = random(10) === 0 ? 40 + random(60) : random(14); i > 0; i--) text += pick(characters); + return text; +} + +const cases = []; +for (let n = 0; n < total; n++) { + const unicode = random(2) === 0; + const flags = ['d', unicode ? 'u' : '', ...['i', 'm', 's'].filter(() => random(3) === 0)]; + const start = random(3); + if (start === 1) flags.push('g'); + if (start === 2) flags.push('y'); + const source = alternation(0, unicode); + const text = subject(); + cases.push({ + id: `dfa-fuzz:${seed}:${n}`, + source, + flags: flags.join(''), + subject: text, + start_utf16: start === 0 ? 0 : random(text.length + 2), + }); +} + +function encode(text) { + const bytes = []; + for (let i = 0; i < text.length; i++) { + let p = text.charCodeAt(i); + if (p >= 0xd800 && p < 0xdc00 && i + 1 < text.length) { + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next < 0xe000) { + p = 0x10000 + ((p - 0xd800) << 10) + (next - 0xdc00); + i++; + } + } + if (p < 0x80) bytes.push(p); + else if (p < 0x800) bytes.push(0xc0 | p >> 6, 0x80 | p & 63); + else if (p < 0x10000) bytes.push(0xe0 | p >> 12, 0x80 | p >> 6 & 63, 0x80 | p & 63); + else bytes.push(0xf0 | p >> 18, 0x80 | p >> 12 & 63, 0x80 | p >> 6 & 63, 0x80 | p & 63); + } + return Buffer.from(bytes).toString('hex'); +} + +const { answers: expected, timedOut } = boundedAnswers(cases); +const run = spawnSync(resolve(probe), [], { + input: cases.map(row => [row.id, encode(row.source), row.flags, encode(row.subject), row.start_utf16].join('\t')).join('\n') + '\n', + encoding: 'utf8', + maxBuffer: 512 * 1024 * 1024, + timeout: 600_000, + env: { ...process.env, PEREX_DFA: process.env.PEREX_DFA ?? '1' }, +}); +assert.ifError(run.error); +assert.equal(run.status, 0, run.stderr); +const actual = parseAnswers(run.stdout); +// A dfa-mismatch is Perex against itself; a Node timeout does not void it. +const mismatches = [...actual.values()].filter(answer => answer.outcome === 'dfa-mismatch'); +for (const row of timedOut) actual.delete(row.id), expected.delete(row.id); +const { differences: all, unstable } = stableDifferences(expected, actual, cases); +// The reviewed Node disagreement in docs/reference-disagreements.md: under `u`, +// V8 lets `\B` match between the halves of a surrogate pair. Perex's +// evaluator does not, and the automaton agrees with the evaluator, so such a +// case differs from Node for a reason already on record. Only a difference +// whose Node match begins between two halves, in a `u` pattern holding `\B`, +// is set aside; every other difference fails. +const byId = new Map(cases.map(row => [row.id, row])); +const betweenHalves = (text, at) => at > 0 && at < text.length && + /[\ud800-\udbff]/.test(text[at - 1]) && /[\udc00-\udfff]/.test(text[at]); +const reviewed = all.filter(d => { + const row = byId.get(d.id); + const spans = (d.expected?.captures ?? []).filter(Boolean).map(c => c.span).flat(); + return row.flags.includes('u') && row.source.includes('\\B') && d.expected?.outcome === 'match' && + spans.some(at => betweenHalves(row.subject, at)); +}); +// The evaluator can exhaust the probe's allowance on a pathological pattern. +// The automaton then answers no-match itself, but a match still needs the +// evaluator for its captures, so such a case stays an execution error. It is +// reported, not counted as a wrong answer. +const exhausted = all.filter(d => !reviewed.includes(d) && + d.actual?.outcome === 'execution-error' && d.actual?.error === 'WorkLimit'); +const differences = all.filter(d => !reviewed.includes(d) && !exhausted.includes(d)); +const outcomes = {}; +for (const answer of actual.values()) outcomes[answer.outcome] = (outcomes[answer.outcome] ?? 0) + 1; +const report = { + node: process.version, + seed, + cases: cases.length, + dfa: process.env.PEREX_DFA ?? '1', + probe: run.stderr.trim().split('\n').pop(), + outcomes, + reference_timeouts: timedOut.length, + unstable_reference_answers: unstable, + dfa_mismatches: mismatches.length, + reviewed_word_boundary_disagreements: reviewed.length, + evaluator_work_limit: exhausted.map(d => d.id), + differences: differences.length, + first_differences: differences.slice(0, 20).map(d => ({ ...d, case: cases.find(row => row.id === d.id) })), +}; +mkdirSync(output, { recursive: true }); +writeFileSync(resolve(output, 'summary.json'), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify(report, null, 2)); +process.exitCode = differences.length || mismatches.length ? 1 : 0;