Skip to main content

vyre_libs/scan/
regex_compile.rs

1//! Regex AST → `NfaPlan` frontend.
2//!
3//! `nfa::compile` ships a literal-only NFA (one byte per state). This
4//! module is its regex-aware counterpart: parse a regex string with
5//! `regex-syntax`, lower the AST into a Thompson NFA over byte
6//! transitions, emit the same `(NfaPlan, transition_table,
7//! epsilon_table)` triple the literal compiler produces.
8//!
9//! # Why a separate module instead of widening `nfa::compile`
10//!
11//! The literal compiler is hot-path simple  -  every byte is a single
12//! state. Bolting alternation / repetition / character classes onto it
13//! would either bloat the literal path or fork the construction code.
14//! The lego-block fix is a SECOND construction module that emits the
15//! SAME output shape, so every downstream component (`nfa_scan`
16//! Program, `mega_scan::build`, `RulePipeline`) works unmodified.
17//!
18//! # Supported regex subset
19//!
20//! Targets the ~85% of vyre's expected detector regex shapes:
21//!
22//!   - Concatenation (default)
23//!   - Alternation `a|b`
24//!   - Character classes `[abc]`, `[a-z]`, `[^abc]`
25//!   - Builtin escapes `\d \D \w \W \s \S` (ASCII semantics)
26//!   - Bounded repetition `*`, `+`, `?`, `{n}`, `{n,m}`
27//!   - Text anchors `^` and `$`
28//!   - Escape literals `\.`, `\\`, `\(`, `\[`
29//!
30//! Explicitly NOT supported (returns `RegexCompileError::Unsupported`):
31//!
32//!   - Backreferences `\1` (NFA cannot represent)
33//!   - Word-boundary and line-boundary lookarounds
34//!   - Unicode character classes outside the ASCII range
35
36use regex_syntax::hir::{Class, Hir, HirKind, Look, Repetition};
37
38use crate::scan::nfa::NfaPlan;
39
40const LANES: usize = vyre_primitives::nfa::subgroup_nfa::LANES_PER_SUBGROUP;
41
42/// Capture output mode a consumer requests for a regex scan, wiring
43/// `docs/optimization/REGEX_CAPTURE_MODE_CONTRACTS.toml` to code.
44///
45/// A consumer routes on this instead of parsing the TOML: [`accelerator_eligible`]
46/// says whether the GPU DFA/AC path can serve the request directly, and
47/// [`verifier_required`] says whether a scalar (CPU-semantics) verifier must own
48/// the output. The three whole-match modes run entirely on the accelerator; the
49/// three group-extraction modes need the verifier (the byte-DFA has no capture
50/// stack). Keeping this a typed enum with one `contract_row` owner means the
51/// routing decision has one home and cannot silently disagree with the contract.
52///
53/// [`accelerator_eligible`]: CaptureMode::accelerator_eligible
54/// [`verifier_required`]: CaptureMode::verifier_required
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum CaptureMode {
57    /// Whole match only, no spans (`whole_match_only`). Accelerator path.
58    NonCapture,
59    /// Match count per pattern (`match_count_per_pattern`). Accelerator path.
60    Count,
61    /// Whole-match `(start, end)` span (`whole_match_span`). Accelerator path.
62    Span,
63    /// Named group span records (`named_group_span_records`). Verifier-bound;
64    /// unmatched group → null.
65    NamedCapture,
66    /// Ordered list of spans for a repeated group (`ordered_group_span_list`).
67    /// Verifier-bound; an empty repeat yields an empty list.
68    RepeatedCapture,
69    /// Row × group value table (`row_group_value_table`). Verifier-bound;
70    /// unmatched group → null.
71    GroupExtraction,
72}
73
74/// Static per-mode contract row mirroring one `[[mode]]` entry of
75/// `REGEX_CAPTURE_MODE_CONTRACTS.toml`. The [`CaptureMode::contract_row`] table
76/// is the single code-side owner; `regex_capture_mode_contracts.rs` locks it to
77/// the TOML so the two cannot drift.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct CaptureModeContract {
80    /// Stable `mode_id` string, identical to the TOML.
81    pub mode_id: &'static str,
82    /// `output_shape` string, identical to the TOML.
83    pub output_shape: &'static str,
84    /// Whether the GPU accelerator path can serve this mode directly.
85    pub accelerator_eligible: bool,
86    /// Whether a scalar verifier must own the output for this mode.
87    pub verifier_required: bool,
88    /// `null_policy` string, identical to the TOML.
89    pub null_policy: &'static str,
90}
91
92impl CaptureMode {
93    /// Every mode, in contract order. One owner for iteration + coherence checks.
94    pub const ALL: [CaptureMode; 6] = [
95        CaptureMode::NonCapture,
96        CaptureMode::Count,
97        CaptureMode::Span,
98        CaptureMode::NamedCapture,
99        CaptureMode::RepeatedCapture,
100        CaptureMode::GroupExtraction,
101    ];
102
103    /// The contract row for this mode, the single code-side source of truth for
104    /// its `mode_id`, `output_shape`, routing bits, and null policy.
105    #[must_use]
106    pub const fn contract_row(self) -> CaptureModeContract {
107        match self {
108            CaptureMode::NonCapture => CaptureModeContract {
109                mode_id: "noncapture",
110                output_shape: "whole_match_only",
111                accelerator_eligible: true,
112                verifier_required: false,
113                null_policy: "not_applicable",
114            },
115            CaptureMode::Count => CaptureModeContract {
116                mode_id: "count",
117                output_shape: "match_count_per_pattern",
118                accelerator_eligible: true,
119                verifier_required: false,
120                null_policy: "not_applicable",
121            },
122            CaptureMode::Span => CaptureModeContract {
123                mode_id: "span",
124                output_shape: "whole_match_span",
125                accelerator_eligible: true,
126                verifier_required: false,
127                null_policy: "absent-match-has-no-span",
128            },
129            CaptureMode::NamedCapture => CaptureModeContract {
130                mode_id: "named_capture",
131                output_shape: "named_group_span_records",
132                accelerator_eligible: false,
133                verifier_required: true,
134                null_policy: "unmatched-group-null",
135            },
136            CaptureMode::RepeatedCapture => CaptureModeContract {
137                mode_id: "repeated_capture",
138                output_shape: "ordered_group_span_list",
139                accelerator_eligible: false,
140                verifier_required: true,
141                null_policy: "empty-repeat-yields-empty-list",
142            },
143            CaptureMode::GroupExtraction => CaptureModeContract {
144                mode_id: "group_extraction",
145                output_shape: "row_group_value_table",
146                accelerator_eligible: false,
147                verifier_required: true,
148                null_policy: "unmatched-group-null",
149            },
150        }
151    }
152
153    /// Whether the GPU accelerator path can serve this mode directly (no
154    /// verifier). The three whole-match modes are eligible; group-extraction is not.
155    #[must_use]
156    pub const fn accelerator_eligible(self) -> bool {
157        self.contract_row().accelerator_eligible
158    }
159
160    /// Whether a scalar (CPU-semantics) verifier must own this mode's output.
161    /// The exact complement of [`accelerator_eligible`](Self::accelerator_eligible)
162    /// under this contract, but named separately because the two are independent
163    /// contract fields, a future mode could be neither (unsupported) rather than
164    /// exactly one.
165    #[must_use]
166    pub const fn verifier_required(self) -> bool {
167        self.contract_row().verifier_required
168    }
169
170    /// Look up a mode by its stable `mode_id` string (the reverse of
171    /// `contract_row().mode_id`), for consumers that receive the mode as config
172    /// text. Returns `None` for an unknown id.
173    #[must_use]
174    pub fn from_mode_id(mode_id: &str) -> Option<CaptureMode> {
175        CaptureMode::ALL
176            .into_iter()
177            .find(|mode| mode.contract_row().mode_id == mode_id)
178    }
179}
180
181/// Failure modes for [`compile_regex_set`]. Variants are non-exhaustive
182/// so future regex features can be added without a breaking change.
183#[derive(Debug, Clone)]
184#[non_exhaustive]
185pub enum RegexCompileError {
186    /// `regex-syntax` rejected the pattern. Carries the parser's own
187    /// diagnostic so callers can forward it.
188    Parse {
189        /// Index into the input slice that failed to parse.
190        pattern_index: usize,
191        /// `regex-syntax`'s error message.
192        message: String,
193    },
194    /// The pattern uses a regex feature this GPU NFA frontend does not
195    /// support. Callers must reject or rewrite the detector into supported
196    /// GPU-NFA rule data.
197    Unsupported {
198        /// Index into the input slice that uses the unsupported feature.
199        pattern_index: usize,
200        /// One-line description of what isn't supported (e.g. "anchors").
201        feature: &'static str,
202    },
203    /// The compiled NFA exceeds `LANES * 32` states (the lane-major
204    /// transition table addresses states with one bit per lane).
205    /// Mitigation: split the pattern set across multiple pipelines.
206    TooManyStates {
207        /// Number of states the AST would have produced.
208        states: usize,
209        /// Per-pipeline maximum.
210        cap: usize,
211    },
212    /// Pattern count does not fit the GPU ABI's `u32` pattern id field.
213    PatternCountOverflow {
214        /// Number of patterns supplied by the caller.
215        count: usize,
216    },
217    /// A compiled regex match length does not fit the `u32` match ABI.
218    MatchLengthOverflow {
219        /// Index into the input slice that produced the oversized match.
220        pattern_index: usize,
221        /// Longest matched byte length for the pattern.
222        len: usize,
223    },
224    /// Transition or epsilon table word count overflowed host `usize`.
225    TableWordCountOverflow {
226        /// Table being built.
227        table: &'static str,
228    },
229    /// Compiler staging allocation failed.
230    StorageReserveFailed {
231        /// Scratch vector being reserved.
232        field: &'static str,
233        /// Requested target capacity.
234        requested: usize,
235        /// Allocator failure details.
236        message: String,
237    },
238}
239
240impl RegexCompileError {
241    /// The canonical `REGEX_UNSUPPORTED_DIAGNOSTICS.toml` diagnostic code for
242    /// this error, or `None` when the error does not correspond to a tracked
243    /// unsupported-construct in that registry.
244    ///
245    /// A consumer routes on this code, e.g. a `*_REQUIRES_VERIFIER` code means
246    /// "send this detector to the scalar verifier", while a `*_UNSUPPORTED_*`
247    /// code means "reject or rewrite". It returns `Some` only for constructs the
248    /// GPU-NFA frontend can distinctly identify AND that have a registry code
249    /// today (ASCII lookaround assertions and over-budget Unicode classes); it
250    /// invents no codes. `Parse` errors, state-budget overflow, and ABI-sizing
251    /// failures return `None`: they are not registry constructs. As the frontend
252    /// learns to distinguish more constructs (backreferences, captures, huge
253    /// alternations, nested repeats), map them here against their registry codes.
254    ///
255    /// The `feature` strings matched below are this crate's own construction-site
256    /// constants (not upstream parser text), so the mapping is stable; the
257    /// `regex_compile_diagnostic_codes` test locks the real compile path to them.
258    #[must_use]
259    pub fn diagnostic_code(&self) -> Option<&'static str> {
260        match self {
261            // The `feature` strings are this crate's own construction-site
262            // constants (below), so the feature→construct→code chain has ONE
263            // owner: `regex_feature_construct` + `regex_construct_diagnostic_code`.
264            Self::Unsupported { feature, .. } => {
265                regex_feature_construct(feature).map(regex_construct_diagnostic_code)
266            }
267            _ => None,
268        }
269    }
270}
271
272/// A regex construct vyre's GPU-NFA frontend distinctly detects AND that has a
273/// canonical `REGEX_UNSUPPORTED_DIAGNOSTICS.toml` diagnostic code.
274///
275/// This enum is the ONE owner of the construct→code mapping. Both
276/// [`RegexCompileError::diagnostic_code`] (for the constructs that surface as a
277/// compile error) and [`CompiledRegexSet::capture_extraction_diagnostic_code`]
278/// (for the non-error capture case) route through
279/// [`regex_construct_diagnostic_code`], so a code string is never written twice.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281#[non_exhaustive]
282pub enum RegexConstruct {
283    /// `\1` / `\k<name>` / `(?P=name)`: not a regular language; rejected.
284    Backreference,
285    /// A non-edge lookaround assertion (`\b`, `(?=…)`, …) (verifier-routed).
286    Lookaround,
287    /// A Unicode character class over the byte-mode GPU expansion budget.
288    UnicodeClassesGpu,
289    /// A capture group whose submatch spans a whole-match engine cannot prove.
290    /// NOT a compile error (whole-match still accelerates; verifier-routed).
291    CaptureExtraction,
292    /// An alternation with more arms than the state budget can ever hold.
293    HugeAlternation,
294    /// Nested bounded repeats whose unroll product exceeds the state budget.
295    NestedRepeats,
296}
297
298/// The canonical `REGEX_UNSUPPORTED_DIAGNOSTICS.toml` code for a construct, the
299/// single source of truth for these strings.
300#[must_use]
301pub fn regex_construct_diagnostic_code(construct: RegexConstruct) -> &'static str {
302    match construct {
303        RegexConstruct::Backreference => "VYRE_SCAN_UNSUPPORTED_BACKREFERENCE",
304        RegexConstruct::Lookaround => "VYRE_SCAN_APPROXIMATED_LOOKAROUND_REQUIRES_VERIFIER",
305        RegexConstruct::UnicodeClassesGpu => "VYRE_SCAN_UNSUPPORTED_UNICODE_MODE_GPU",
306        RegexConstruct::CaptureExtraction => "VYRE_SCAN_CAPTURE_EXTRACTION_REQUIRES_VERIFIER",
307        RegexConstruct::HugeAlternation => "VYRE_SCAN_UNSUPPORTED_HUGE_ALTERNATION_BUDGET",
308        RegexConstruct::NestedRepeats => "VYRE_SCAN_UNSUPPORTED_NESTED_REPEAT_BUDGET",
309    }
310}
311
312// Feature strings carried by `RegexCompileError::Unsupported`. Defined ONCE here
313// and used at every construction site AND by `regex_feature_construct`, so the
314// error text and the diagnostic mapping cannot drift apart.
315const FEATURE_LOOKAROUND: &str = "non-edge lookaround assertion";
316const FEATURE_UNICODE_CLASS_CAP: &str = "unicode character class exceeded expansion cap";
317const FEATURE_BACKREFERENCE: &str = "backreference";
318const FEATURE_HUGE_ALTERNATION: &str = "huge alternation exceeds budget";
319const FEATURE_NESTED_REPEATS: &str = "nested repeat exceeds budget";
320
321/// Map an `Unsupported { feature }` string back to its construct. Returns `None`
322/// for feature strings that are real GPU-NFA limits but have no registry code
323/// (e.g. the empty/byte-class expansion caps), so `diagnostic_code` invents none.
324fn regex_feature_construct(feature: &str) -> Option<RegexConstruct> {
325    match feature {
326        FEATURE_LOOKAROUND => Some(RegexConstruct::Lookaround),
327        FEATURE_UNICODE_CLASS_CAP => Some(RegexConstruct::UnicodeClassesGpu),
328        FEATURE_BACKREFERENCE => Some(RegexConstruct::Backreference),
329        FEATURE_HUGE_ALTERNATION => Some(RegexConstruct::HugeAlternation),
330        FEATURE_NESTED_REPEATS => Some(RegexConstruct::NestedRepeats),
331        _ => None,
332    }
333}
334
335impl std::fmt::Display for RegexCompileError {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        match self {
338            Self::Parse {
339                pattern_index,
340                message,
341            } => write!(
342                f,
343                "regex_compile: pattern {pattern_index} parse error: {message}. \
344                 Fix: review the regex syntax."
345            ),
346            Self::Unsupported {
347                pattern_index,
348                feature,
349            } => write!(
350                f,
351                "regex_compile: pattern {pattern_index} uses unsupported feature `{feature}`. \
352                 Fix: rewrite the detector into supported GPU-NFA syntax or split it into GPU-compatible rules."
353            ),
354            Self::TooManyStates { states, cap } => write!(
355                f,
356                "regex_compile: NFA needs {states} states; per-pipeline cap is {cap}. \
357                 Fix: split the pattern set across multiple pipelines."
358            ),
359            Self::PatternCountOverflow { count } => write!(
360                f,
361                "regex_compile: pattern count {count} exceeds u32 capacity. Fix: shard the pattern set before GPU regex compilation."
362            ),
363            Self::MatchLengthOverflow {
364                pattern_index,
365                len,
366            } => write!(
367                f,
368                "regex_compile: pattern {pattern_index} match length {len} exceeds u32 capacity. Fix: bound or shard the regex before GPU compilation."
369            ),
370            Self::TableWordCountOverflow { table } => write!(
371                f,
372                "regex_compile: {table} table word count overflows host usize. Fix: shard the regex pattern set before table construction."
373            ),
374            Self::StorageReserveFailed {
375                field,
376                requested,
377                message,
378            } => write!(
379                f,
380                "regex_compile: could not reserve {requested} {field} slot(s): {message}. Fix: shard the regex pattern set before GPU compilation."
381            ),
382        }
383    }
384}
385
386impl std::error::Error for RegexCompileError {}
387
388/// Output of [`compile_regex_set`]  -  same triple shape as the literal
389/// `nfa::compile` returns plus the GPU side-tables `nfa::nfa_scan`
390/// expects, so consumers can plug this into `RulePipeline` without
391/// changing the dispatch path.
392#[derive(Debug, Clone)]
393pub struct CompiledRegexSet {
394    /// State graph + accept-state metadata.
395    pub plan: NfaPlan,
396    /// Lane-major byte→bitset transition table:
397    /// `[num_states × 256 × LANES_PER_SUBGROUP]` u32s.
398    pub transition_table: Vec<u32>,
399    /// Lane-major epsilon (free) transition table:
400    /// `[num_states × LANES_PER_SUBGROUP]` u32s.
401    pub epsilon_table: Vec<u32>,
402    /// `true` when at least one source pattern contained a capture group.
403    ///
404    /// The GPU NFA is a WHOLE-MATCH multimatch engine: it accelerates the
405    /// match decision but does NOT prove submatch (capture) spans, capture
406    /// groups are stripped during lowering (whole-match still compiles and
407    /// runs correctly). A consumer that needs submatch offsets must route
408    /// these patterns to the scalar verifier; this flag is the distinct signal
409    /// for the `VYRE_SCAN_CAPTURE_EXTRACTION_REQUIRES_VERIFIER` diagnostic
410    /// (see [`regex_construct_diagnostic_code`]) WITHOUT rejecting the pattern
411    /// (making captures a compile error would regress whole-match acceleration).
412    pub captures_present: bool,
413}
414
415impl CompiledRegexSet {
416    /// The `REGEX_UNSUPPORTED_DIAGNOSTICS.toml` code a consumer routes on when
417    /// it needs submatch (capture) spans this whole-match GPU engine does not
418    /// prove, or `None` when the compiled set has no capture groups.
419    ///
420    /// This is NOT an error: the set compiled and scans correctly for the
421    /// whole-match decision. The code tells a consumer that wants capture
422    /// offsets to run the scalar capture verifier for these patterns.
423    #[must_use]
424    pub fn capture_extraction_diagnostic_code(&self) -> Option<&'static str> {
425        self.captures_present
426            .then_some(regex_construct_diagnostic_code(
427                RegexConstruct::CaptureExtraction,
428            ))
429    }
430}
431
432const STATE_CAP: usize = LANES * 32;
433
434/// An alternation with more arms than this can NEVER fit the state budget
435/// (each arm needs ≥1 state, plus the fork + join), so it is distinctly
436/// diagnosed as a huge alternation instead of collapsing into a generic
437/// `TooManyStates`. Equal to `STATE_CAP` so the reclassification is SOUND: any
438/// alternation this wide already overflowed and never compiled, no successful
439/// compile is turned into an error.
440const MAX_ALTERNATION_ARMS: usize = STATE_CAP;
441
442/// Nested bounded repeats unroll to (product of the bounds) copies of the body,
443/// and each copy is ≥1 state, so when that product exceeds this budget the NFA
444/// provably cannot fit. Such patterns are distinctly diagnosed as a nested-repeat
445/// blowup rather than a generic `TooManyStates`. Equal to `STATE_CAP` (the unroll
446/// product lower-bounds the state count), so no currently-compiling nested
447/// repeat regresses.
448const NESTED_REPEAT_UNROLL_BUDGET: u64 = STATE_CAP as u64;
449
450/// Non-error signals gathered by [`scan_constructs`] while it validates budgets.
451struct ConstructScan {
452    /// A capture group was seen (whole-match compiles; submatch spans are not
453    /// proven (a verifier-routed signal, never a compile error)).
454    captures_present: bool,
455}
456
457/// Walk a parsed HIR once to (a) reject over-budget constructs with a DISTINCT
458/// diagnostic, huge alternations and nested bounded repeats. BEFORE lowering
459/// collapses them into a generic `TooManyStates`, and (b) record whether any
460/// capture group is present. Returns the worst-case bounded-repeat unroll
461/// product of `hir`, so a parent repetition can detect multiplicative nesting.
462fn scan_constructs(
463    hir: &Hir,
464    pid: usize,
465    scan: &mut ConstructScan,
466) -> Result<u64, RegexCompileError> {
467    match hir.kind() {
468        HirKind::Alternation(alts) => {
469            if alts.len() > MAX_ALTERNATION_ARMS {
470                return Err(RegexCompileError::Unsupported {
471                    pattern_index: pid,
472                    feature: FEATURE_HUGE_ALTERNATION,
473                });
474            }
475            let mut worst = 1u64;
476            for a in alts {
477                worst = worst.max(scan_constructs(a, pid, scan)?);
478            }
479            Ok(worst)
480        }
481        HirKind::Concat(parts) => {
482            let mut worst = 1u64;
483            for p in parts {
484                worst = worst.max(scan_constructs(p, pid, scan)?);
485            }
486            Ok(worst)
487        }
488        HirKind::Repetition(rep) => {
489            let inner = scan_constructs(&rep.sub, pid, scan)?;
490            match rep.max {
491                Some(m) => {
492                    let product = u64::from(m).saturating_mul(inner.max(1));
493                    // `inner > 1` means a bounded repeat is NESTED inside this
494                    // bounded repeat (the case that multiplicatively explodes).
495                    // A flat `a{5000}` (inner == 1) is left to the per-repeat
496                    // `TooManyStates` guard, unchanged.
497                    if inner > 1 && product > NESTED_REPEAT_UNROLL_BUDGET {
498                        return Err(RegexCompileError::Unsupported {
499                            pattern_index: pid,
500                            feature: FEATURE_NESTED_REPEATS,
501                        });
502                    }
503                    Ok(product)
504                }
505                // Unbounded (`*` / `+`) lowers to an O(1) Kleene wrapper: it does
506                // not multiply the nesting product.
507                None => Ok(inner.max(1)),
508            }
509        }
510        HirKind::Capture(c) => {
511            scan.captures_present = true;
512            scan_constructs(&c.sub, pid, scan)
513        }
514        _ => Ok(1),
515    }
516}
517
518/// Structured scan for a backreference construct (`\1`..`\9`, `\k<name>` /
519/// `\k'name'` / `\k{name}`, or `(?P=name)`). `regex-syntax` does not support
520/// backreferences at all, they surface as a raw parse error, so this runs
521/// ONLY on the parse-failure path, to CLASSIFY the failure as the distinct
522/// unsupported construct rather than a generic syntax error. It respects
523/// backslash escaping: an escaped backslash (`\\`) consumes both bytes, so the
524/// following digit is read as a literal, not a backreference.
525fn pattern_uses_backreference(pat: &str) -> bool {
526    let bytes = pat.as_bytes();
527    let mut i = 0;
528    while i < bytes.len() {
529        match bytes[i] {
530            b'\\' => {
531                if let Some(&c) = bytes.get(i + 1) {
532                    // Numeric backreference `\1`..`\9` (`\0` is a NUL escape).
533                    if c.is_ascii_digit() && c != b'0' {
534                        return true;
535                    }
536                    // Named backreference `\k<name>` / `\k'name'` / `\k{name}`.
537                    if c == b'k' && matches!(bytes.get(i + 2), Some(b'<' | b'\'' | b'{')) {
538                        return true;
539                    }
540                }
541                // Skip the escape AND the escaped byte so `\\` is not misread.
542                i += 2;
543            }
544            // Python-style named backreference `(?P=name)`. `bytes[i]` is the
545            // ASCII `(`, so `i` is a char boundary and the slice is safe.
546            b'(' if pat[i..].starts_with("(?P=") => return true,
547            _ => i += 1,
548        }
549    }
550    false
551}
552
553/// Compile a list of regex strings into a single multimatch NFA.
554///
555/// # Errors
556/// See [`RegexCompileError`].
557pub fn compile_regex_set(patterns: &[&str]) -> Result<CompiledRegexSet, RegexCompileError> {
558    let mut builder = NfaBuilder::new();
559    let _pattern_count =
560        u32::try_from(patterns.len()).map_err(|_| RegexCompileError::PatternCountOverflow {
561            count: patterns.len(),
562        })?;
563    let mut accept_states = Vec::new();
564    reserve_vec(&mut accept_states, patterns.len(), "accept state")?;
565    let mut accept_state_ids = Vec::new();
566    reserve_vec(&mut accept_state_ids, patterns.len(), "accept state id")?;
567    let mut accept_start_anchored = Vec::new();
568    reserve_vec(
569        &mut accept_start_anchored,
570        patterns.len(),
571        "accept start-anchor flag",
572    )?;
573    let mut accept_end_anchored = Vec::new();
574    reserve_vec(
575        &mut accept_end_anchored,
576        patterns.len(),
577        "accept end-anchor flag",
578    )?;
579    let entry = builder.fresh_state()?; // shared entry state 0
580    let mut captures_present = false;
581
582    // Use the byte-oriented parser configuration: `unicode(false)` +
583    // `utf8(false)` makes `\d` / `\w` / `\s` ASCII-only, which is what
584    // this primitive's byte-state automaton can represent.
585    // `regex_syntax::parse(pat)` defaults to Unicode classes that
586    // explode into hundreds of byte ranges and trip our `> 0x7F` guard.
587    for (pid, pat) in patterns.iter().enumerate() {
588        // Two-phase parse: byte-mode first (keeps `\d`/`\w`/`\s` ASCII
589        // so they don't explode into hundreds of Unicode codepoint
590        // ranges), then unicode-mode as a fallback when the source
591        // contains a non-ASCII codepoint inside a character class
592        // (e.g. homoglyph-expanded `[hнһh]`). The unicode-mode HIR
593        // gets the same `build_class` lowering - non-ASCII members
594        // expand into UTF-8 byte-sequence alternations.
595        let hir = match regex_syntax::ParserBuilder::new()
596            .unicode(false)
597            .utf8(false)
598            .build()
599            .parse(pat)
600        {
601            Ok(h) => h,
602            Err(byte_mode_err) => match regex_syntax::ParserBuilder::new()
603                .unicode(true)
604                .utf8(false)
605                .build()
606                .parse(pat)
607            {
608                Ok(h) => h,
609                Err(_unicode_err) => {
610                    // Both grammars rejected it. Classify a backreference
611                    // which `regex-syntax` never supports, as its DISTINCT
612                    // unsupported construct instead of a generic parse error,
613                    // so a consumer can route on the registry code. Everything
614                    // else keeps the byte-mode diagnostic (the narrow grammar
615                    // the kernel actually supports; the unicode retry only
616                    // widens the character-class path).
617                    if pattern_uses_backreference(pat) {
618                        return Err(RegexCompileError::Unsupported {
619                            pattern_index: pid,
620                            feature: FEATURE_BACKREFERENCE,
621                        });
622                    }
623                    return Err(RegexCompileError::Parse {
624                        pattern_index: pid,
625                        message: format!("{byte_mode_err}"),
626                    });
627                }
628            },
629        };
630        // Validate construct budgets (huge alternation / nested repeats) with a
631        // DISTINCT diagnostic before lowering collapses them into a generic
632        // `TooManyStates`, and note capture presence (a non-error signal).
633        let mut construct_scan = ConstructScan {
634            captures_present: false,
635        };
636        scan_constructs(&hir, pid, &mut construct_scan)?;
637        captures_present |= construct_scan.captures_present;
638        let (frag, anchors) = build_pattern_hir(&mut builder, &hir, pid)?;
639        // Connect the shared entry to this pattern's start via epsilon.
640        builder.add_epsilon(entry, frag.start);
641        let pid_u32 = u32::try_from(pid).map_err(|_| RegexCompileError::PatternCountOverflow {
642            count: patterns.len(),
643        })?;
644        let match_len_u32 =
645            u32::try_from(frag.match_len).map_err(|_| RegexCompileError::MatchLengthOverflow {
646                pattern_index: pid,
647                len: frag.match_len,
648            })?;
649        accept_states.push((pid_u32, match_len_u32));
650        accept_state_ids.push(frag.end);
651        accept_start_anchored.push(anchors.start);
652        accept_end_anchored.push(anchors.end);
653    }
654
655    if builder.state_count() > STATE_CAP {
656        return Err(RegexCompileError::TooManyStates {
657            states: builder.state_count(),
658            cap: STATE_CAP,
659        });
660    }
661
662    let plan = NfaPlan {
663        num_states: u32::try_from(builder.state_count()).map_err(|_| {
664            RegexCompileError::TooManyStates {
665                states: builder.state_count(),
666                cap: STATE_CAP,
667            }
668        })?,
669        input_len: 0,
670        accept_states,
671        accept_state_ids,
672        accept_start_anchored,
673        accept_end_anchored,
674    };
675    let (transition_table, epsilon_table) = builder.emit_lane_major_tables()?;
676    Ok(CompiledRegexSet {
677        plan,
678        transition_table,
679        epsilon_table,
680        captures_present,
681    })
682}
683
684/// Build a [`crate::scan::RulePipeline`] directly from regex
685/// sources. Convenience for consumers who don't need the
686/// `CompiledRegexSet` intermediate. `input_len` matches the contract
687/// of `mega_scan::build` (haystack byte count the dispatch will scan).
688///
689/// # Errors
690/// Forwards [`RegexCompileError`].
691pub fn build_rule_pipeline_from_regex(
692    patterns: &[&str],
693    input_buf: &str,
694    hit_buf: &str,
695    input_len: u32,
696) -> Result<crate::scan::RulePipeline, RegexCompileError> {
697    let compiled = compile_regex_set(patterns)?;
698    let has_epsilon = compiled.epsilon_table.iter().any(|word| *word != 0);
699    let program = crate::scan::nfa::nfa_scan_with_plan(
700        &compiled.plan,
701        has_epsilon,
702        input_buf,
703        hit_buf,
704        input_len,
705    )
706    .map_err(|_| RegexCompileError::TooManyStates {
707        states: compiled.plan.num_states as usize,
708        cap: STATE_CAP,
709    })?;
710    Ok(crate::scan::RulePipeline {
711        program,
712        transition_table: compiled.transition_table,
713        epsilon_table: compiled.epsilon_table,
714        plan: compiled.plan.for_input_len(input_len),
715    })
716}
717
718// ---- Thompson NFA construction over byte transitions ----
719
720#[derive(Debug)]
721struct NfaBuilder {
722    state_count: usize,
723    /// Flat byte transitions. Emission consumes the stream directly,
724    /// so construction does not need one allocation per NFA state.
725    transitions: Vec<ByteTransition>,
726    /// Flat epsilon (free) transitions.
727    epsilons: Vec<(u32, u32)>,
728}
729
730#[derive(Debug, Clone)]
731struct ByteTransition {
732    src: u32,
733    set: ByteSet,
734    dst: u32,
735}
736
737#[derive(Debug, Clone)]
738struct ByteSet {
739    bits: [u64; 4], // 256 bits → 4 u64s
740}
741
742impl ByteSet {
743    fn new() -> Self {
744        Self { bits: [0; 4] }
745    }
746    fn insert(&mut self, b: u8) {
747        self.bits[(b / 64) as usize] |= 1u64 << (b % 64);
748    }
749    fn from_byte(b: u8) -> Self {
750        let mut s = Self::new();
751        s.insert(b);
752        s
753    }
754    fn from_range(lo: u8, hi: u8) -> Self {
755        let mut s = Self::new();
756        for b in lo..=hi {
757            s.insert(b);
758        }
759        s
760    }
761    fn for_each_set_byte(&self, mut f: impl FnMut(u8)) {
762        for (word_idx, &word) in self.bits.iter().enumerate() {
763            let mut bits = word;
764            while bits != 0 {
765                let bit = bits.trailing_zeros() as usize;
766                f((word_idx * 64 + bit) as u8);
767                bits &= bits - 1;
768            }
769        }
770    }
771}
772
773#[derive(Debug, Clone, Copy)]
774struct Fragment {
775    start: u32,
776    end: u32,
777    /// Sum of byte-steps along the longest path. Used as the
778    /// `pattern_len` reported in `NfaPlan::accept_states`.
779    match_len: usize,
780}
781
782#[derive(Debug, Clone, Copy, Default)]
783struct PatternAnchors {
784    start: bool,
785    end: bool,
786}
787
788impl NfaBuilder {
789    fn new() -> Self {
790        Self {
791            state_count: 0,
792            transitions: Vec::new(),
793            epsilons: Vec::new(),
794        }
795    }
796
797    fn state_count(&self) -> usize {
798        self.state_count
799    }
800
801    fn fresh_state(&mut self) -> Result<u32, RegexCompileError> {
802        if self.state_count >= STATE_CAP {
803            return Err(RegexCompileError::TooManyStates {
804                states: self.state_count.saturating_add(1),
805                cap: STATE_CAP,
806            });
807        }
808        let state =
809            u32::try_from(self.state_count).map_err(|_| RegexCompileError::TooManyStates {
810                states: self.state_count,
811                cap: STATE_CAP,
812            })?;
813        self.state_count =
814            self.state_count
815                .checked_add(1)
816                .ok_or(RegexCompileError::TooManyStates {
817                    states: usize::MAX,
818                    cap: STATE_CAP,
819                })?;
820        Ok(state)
821    }
822
823    fn add_byte_transition(&mut self, src: u32, set: ByteSet, dst: u32) {
824        self.transitions.push(ByteTransition { src, set, dst });
825    }
826
827    fn add_epsilon(&mut self, src: u32, dst: u32) {
828        self.epsilons.push((src, dst));
829    }
830
831    /// Lane-major emission, matching the contract of
832    /// `nfa::build_transition_table` + `build_epsilon_table`.
833    fn emit_lane_major_tables(&self) -> Result<(Vec<u32>, Vec<u32>), RegexCompileError> {
834        let n = self.state_count();
835        let mut transitions = zeroed_u32_table(
836            table_word_count(n, 256, "transition")?,
837            "transition table word",
838        )?;
839        let mut epsilons =
840            zeroed_u32_table(table_word_count(n, 1, "epsilon")?, "epsilon table word")?;
841
842        for edge in &self.transitions {
843            let src = edge.src as usize;
844            let dst_lane = (edge.dst / 32) as usize;
845            let dst_bit = 1u32 << (edge.dst % 32);
846            edge.set.for_each_set_byte(|byte| {
847                let idx = src * 256 * LANES + (byte as usize) * LANES + dst_lane;
848                transitions[idx] |= dst_bit;
849            });
850        }
851        for &(src, dst) in &self.epsilons {
852            let dst_lane = (dst / 32) as usize;
853            let dst_bit = 1u32 << (dst % 32);
854            let idx = src as usize * LANES + dst_lane;
855            epsilons[idx] |= dst_bit;
856        }
857        Ok((transitions, epsilons))
858    }
859}
860
861fn table_word_count(
862    states: usize,
863    byte_columns: usize,
864    table: &'static str,
865) -> Result<usize, RegexCompileError> {
866    states
867        .checked_mul(byte_columns)
868        .and_then(|words| words.checked_mul(LANES))
869        .ok_or(RegexCompileError::TableWordCountOverflow { table })
870}
871
872fn zeroed_u32_table(words: usize, field: &'static str) -> Result<Vec<u32>, RegexCompileError> {
873    let mut table = Vec::new();
874    reserve_vec(&mut table, words, field)?;
875    table.resize(words, 0);
876    Ok(table)
877}
878
879fn reserve_vec<T>(
880    vec: &mut Vec<T>,
881    requested: usize,
882    field: &'static str,
883) -> Result<(), RegexCompileError> {
884    vyre_foundation::allocation::try_reserve_vec_to_capacity(vec, requested).map_err(|source| {
885        RegexCompileError::StorageReserveFailed {
886            field,
887            requested,
888            message: source.to_string(),
889        }
890    })
891}
892
893fn empty_fragment(b: &mut NfaBuilder) -> Result<Fragment, RegexCompileError> {
894    let s = b.fresh_state()?;
895    Ok(Fragment {
896        start: s,
897        end: s,
898        match_len: 0,
899    })
900}
901
902fn build_pattern_hir(
903    b: &mut NfaBuilder,
904    hir: &Hir,
905    pid: usize,
906) -> Result<(Fragment, PatternAnchors), RegexCompileError> {
907    match hir.kind() {
908        HirKind::Look(Look::Start) => Ok((
909            empty_fragment(b)?,
910            PatternAnchors {
911                start: true,
912                end: false,
913            },
914        )),
915        HirKind::Look(Look::End) => Ok((
916            empty_fragment(b)?,
917            PatternAnchors {
918                start: false,
919                end: true,
920            },
921        )),
922        HirKind::Concat(parts) => {
923            let mut first = 0usize;
924            let mut last = parts.len();
925            let mut anchors = PatternAnchors::default();
926
927            if first < last && is_text_start_look(&parts[first]) {
928                anchors.start = true;
929                first += 1;
930            }
931            if first < last && is_text_end_look(&parts[last - 1]) {
932                anchors.end = true;
933                last -= 1;
934            }
935
936            Ok((build_hir_slice(b, &parts[first..last], pid)?, anchors))
937        }
938        _ => Ok((build_hir(b, hir, pid)?, PatternAnchors::default())),
939    }
940}
941
942fn is_text_start_look(hir: &Hir) -> bool {
943    matches!(hir.kind(), HirKind::Look(Look::Start))
944}
945
946fn is_text_end_look(hir: &Hir) -> bool {
947    matches!(hir.kind(), HirKind::Look(Look::End))
948}
949
950fn build_hir_slice(
951    b: &mut NfaBuilder,
952    parts: &[Hir],
953    pid: usize,
954) -> Result<Fragment, RegexCompileError> {
955    let Some(first_part) = parts.first() else {
956        return empty_fragment(b);
957    };
958    let mut acc = build_hir(b, first_part, pid)?;
959    for child in &parts[1..] {
960        let next = build_hir(b, child, pid)?;
961        b.add_epsilon(acc.end, next.start);
962        acc = Fragment {
963            start: acc.start,
964            end: next.end,
965            match_len: acc.match_len + next.match_len,
966        };
967    }
968    Ok(acc)
969}
970
971fn build_hir(b: &mut NfaBuilder, hir: &Hir, pid: usize) -> Result<Fragment, RegexCompileError> {
972    match hir.kind() {
973        HirKind::Empty => empty_fragment(b),
974        HirKind::Literal(lit) => {
975            // Each literal byte gets its own state.
976            let start = b.fresh_state()?;
977            let mut prev = start;
978            for &byte in lit.0.iter() {
979                let next = b.fresh_state()?;
980                b.add_byte_transition(prev, ByteSet::from_byte(byte), next);
981                prev = next;
982            }
983            Ok(Fragment {
984                start,
985                end: prev,
986                match_len: lit.0.len(),
987            })
988        }
989        HirKind::Class(cls) => build_class(b, cls, pid),
990        HirKind::Repetition(rep) => build_repetition(b, rep, pid),
991        HirKind::Concat(parts) => build_hir_slice(b, parts, pid),
992        HirKind::Alternation(alts) => {
993            // Diamond: shared fork → each branch → shared join.
994            let fork = b.fresh_state()?;
995            let join = b.fresh_state()?;
996            let mut max_len = 0usize;
997            for child in alts {
998                let frag = build_hir(b, child, pid)?;
999                b.add_epsilon(fork, frag.start);
1000                b.add_epsilon(frag.end, join);
1001                if frag.match_len > max_len {
1002                    max_len = frag.match_len;
1003                }
1004            }
1005            Ok(Fragment {
1006                start: fork,
1007                end: join,
1008                match_len: max_len,
1009            })
1010        }
1011        HirKind::Look(_) => Err(RegexCompileError::Unsupported {
1012            pattern_index: pid,
1013            feature: FEATURE_LOOKAROUND,
1014        }),
1015        HirKind::Capture(c) => {
1016            // We don't expose capture groups (NFA scan is multimatch,
1017            // not capture). Strip and recurse.
1018            build_hir(b, &c.sub, pid)
1019        }
1020    }
1021}
1022
1023fn build_repetition(
1024    b: &mut NfaBuilder,
1025    rep: &Repetition,
1026    pid: usize,
1027) -> Result<Fragment, RegexCompileError> {
1028    let min = rep.min;
1029    let max = rep.max;
1030
1031    // Keep pathological repetitions from materializing a giant transient NFA.
1032    // The final state cap is the source of truth, so oversized repetitions
1033    // report TooManyStates instead of pretending the syntax is unsupported.
1034    if let Some(m) = max {
1035        if m as usize > STATE_CAP {
1036            return Err(RegexCompileError::TooManyStates {
1037                states: m as usize,
1038                cap: STATE_CAP,
1039            });
1040        }
1041    }
1042    if min as usize > STATE_CAP {
1043        return Err(RegexCompileError::TooManyStates {
1044            states: min as usize,
1045            cap: STATE_CAP,
1046        });
1047    }
1048
1049    // Build by unrolling: emit `min` copies, then either
1050    //   - a Kleene loop if max is None (`*` / `+`), OR
1051    //   - `max - min` optional copies if max is bounded.
1052    let start = b.fresh_state()?;
1053    let mut tail = start;
1054    let mut total_len = 0usize;
1055
1056    for _ in 0..min {
1057        let frag = build_hir(b, &rep.sub, pid)?;
1058        b.add_epsilon(tail, frag.start);
1059        tail = frag.end;
1060        total_len += frag.match_len;
1061    }
1062
1063    match max {
1064        None => {
1065            // Open-ended: insert a Kleene wrapper. tail → frag.start →
1066            // frag.end → tail (loop back) ; tail → join (skip).
1067            let join = b.fresh_state()?;
1068            let frag = build_hir(b, &rep.sub, pid)?;
1069            b.add_epsilon(tail, frag.start);
1070            b.add_epsilon(frag.end, frag.start); // loop
1071            b.add_epsilon(frag.end, join);
1072            b.add_epsilon(tail, join); // zero matches
1073            tail = join;
1074        }
1075        Some(m) => {
1076            for _ in min..m {
1077                let frag = build_hir(b, &rep.sub, pid)?;
1078                let join = b.fresh_state()?;
1079                b.add_epsilon(tail, frag.start);
1080                b.add_epsilon(frag.end, join);
1081                b.add_epsilon(tail, join); // skip this optional copy
1082                tail = join;
1083                // `match_len` is the MAXIMUM admissible match length (see
1084                // `build_class`: extraction uses it only to size the replay
1085                // window, so over-sizing is harmless but UNDER-sizing truncates
1086                // the walk before the longer accepts). A bounded repetition
1087                // `{n,m}` accepts every length in `n..=m` (the ε skip edges make
1088                // the fragment end reachable after each optional copy), so the
1089                // window must cover the MAX `m` copies, otherwise the anchored
1090                // windowed replay caps at `n` and never visits ends `n+1..=m`
1091                // (the root of BACKLOG items 18/27: `a{2,4}` surfaced only
1092                // length-2, and `{10,48}` under-scanned). Accumulate every
1093                // optional copy so `total_len` reaches `m * sub_len`.
1094                total_len += frag.match_len;
1095            }
1096        }
1097    }
1098    Ok(Fragment {
1099        start,
1100        end: tail,
1101        match_len: total_len,
1102    })
1103}
1104
1105/// Lower a regex character class into an NFA fragment, taking the
1106/// single-byte fast path when the class fits in 0..=127 and the
1107/// UTF-8-alternation expansion path otherwise.
1108///
1109/// The single-byte path is identical to the original implementation:
1110/// one ByteSet, one transition, `match_len = 1`. The expansion path
1111/// emits one byte-chain fragment per codepoint (or per pre-existing
1112/// multi-byte range like `\u{0100}-\u{01FF}` enumerated codepoint-by-
1113/// codepoint) and ε-merges them via a shared end state.
1114///
1115/// `match_len` for the expansion case is the MAX byte length across
1116/// arms - anchored extraction uses `match_len` only to position
1117/// the post-process window, not to extract the credential text, and
1118/// over-sizing the window is harmless (the real regex re-extracts the
1119/// exact match inside it).
1120///
1121/// To keep state-budget worst case bounded, expansion is capped at
1122/// `MAX_CLASS_EXPANSION_CODEPOINTS = 256` enumerated codepoints (a
1123/// `[\u{0100}-\u{017F}]` Latin-Extended block sits at 128, which is
1124/// well within budget; a class spanning a full CJK block would refuse).
1125fn build_class(b: &mut NfaBuilder, cls: &Class, pid: usize) -> Result<Fragment, RegexCompileError> {
1126    if let Some(set) = try_class_as_ascii_byte_set(cls) {
1127        let start = b.fresh_state()?;
1128        let end = b.fresh_state()?;
1129        b.add_byte_transition(start, set, end);
1130        return Ok(Fragment {
1131            start,
1132            end,
1133            match_len: 1,
1134        });
1135    }
1136    let sequences = class_to_utf8_sequences(cls, pid)?;
1137    if sequences.is_empty() {
1138        return Err(RegexCompileError::Unsupported {
1139            pattern_index: pid,
1140            feature: "empty character class after Unicode expansion",
1141        });
1142    }
1143    let start = b.fresh_state()?;
1144    let end = b.fresh_state()?;
1145    let mut max_len = 1usize;
1146    for seq in &sequences {
1147        if seq.is_empty() {
1148            continue;
1149        }
1150        // Build a sequential chain start ε→ s0 -b0-> s1 -b1-> ... -bN-> end
1151        // for this UTF-8 byte sequence.
1152        let arm_start = b.fresh_state()?;
1153        b.add_epsilon(start, arm_start);
1154        let mut prev = arm_start;
1155        for &byte in seq {
1156            let next = b.fresh_state()?;
1157            b.add_byte_transition(prev, ByteSet::from_byte(byte), next);
1158            prev = next;
1159        }
1160        b.add_epsilon(prev, end);
1161        if seq.len() > max_len {
1162            max_len = seq.len();
1163        }
1164    }
1165    Ok(Fragment {
1166        start,
1167        end,
1168        match_len: max_len,
1169    })
1170}
1171
1172/// Returns `Some(ByteSet)` when every member of the class fits in
1173/// 0..=127 (i.e. the original single-byte ASCII fast path). Otherwise
1174/// returns None so the caller takes the UTF-8 expansion path.
1175fn try_class_as_ascii_byte_set(cls: &Class) -> Option<ByteSet> {
1176    let mut out = ByteSet::new();
1177    match cls {
1178        Class::Bytes(byte_class) => {
1179            // Byte classes are already at the byte level - every member
1180            // is a u8, no codepoint expansion involved. The legacy fast
1181            // path always applies.
1182            for r in byte_class.iter() {
1183                let merged = ByteSet::from_range(r.start(), r.end());
1184                for w in 0..4 {
1185                    out.bits[w] |= merged.bits[w];
1186                }
1187            }
1188            Some(out)
1189        }
1190        Class::Unicode(uni) => {
1191            // ASCII-only fast path. The moment any range escapes
1192            // 0..=0x7F, fall through to UTF-8 expansion.
1193            for r in uni.iter() {
1194                if (r.end() as u32) > 0x7F {
1195                    return None;
1196                }
1197                let merged = ByteSet::from_range(r.start() as u8, r.end() as u8);
1198                for w in 0..4 {
1199                    out.bits[w] |= merged.bits[w];
1200                }
1201            }
1202            Some(out)
1203        }
1204    }
1205}
1206
1207/// Cap on enumerated codepoints during UTF-8 expansion. A class like
1208/// `[\u{0100}-\u{017F}]` (Latin Extended-A) expands to 128 sequences,
1209/// well within the cap. A class spanning a full CJK block (~20 000
1210/// codepoints) would blow past it - the byte-state automaton can't
1211/// represent that cleanly, so the consumer should keep that pattern on
1212/// the CPU regex path.
1213const MAX_CLASS_EXPANSION_CODEPOINTS: usize = 256;
1214
1215/// Enumerate every codepoint in `cls`, encode each into UTF-8, and
1216/// return the resulting `Vec<Vec<u8>>` so the caller can build an
1217/// alternation of byte-chain fragments. ASCII members come back as
1218/// 1-byte sequences; non-ASCII as 2-4 byte sequences.
1219fn class_to_utf8_sequences(cls: &Class, pid: usize) -> Result<Vec<Vec<u8>>, RegexCompileError> {
1220    let mut sequences: Vec<Vec<u8>> = Vec::new();
1221    let mut budget = MAX_CLASS_EXPANSION_CODEPOINTS;
1222    match cls {
1223        Class::Bytes(byte_class) => {
1224            for r in byte_class.iter() {
1225                for byte in r.start()..=r.end() {
1226                    if budget == 0 {
1227                        return Err(RegexCompileError::Unsupported {
1228                            pattern_index: pid,
1229                            feature: "byte character class exceeded expansion cap",
1230                        });
1231                    }
1232                    sequences.push(vec![byte]);
1233                    budget -= 1;
1234                }
1235            }
1236        }
1237        Class::Unicode(uni) => {
1238            for r in uni.iter() {
1239                let lo = r.start() as u32;
1240                let hi = r.end() as u32;
1241                for cp in lo..=hi {
1242                    if budget == 0 {
1243                        return Err(RegexCompileError::Unsupported {
1244                            pattern_index: pid,
1245                            feature: FEATURE_UNICODE_CLASS_CAP,
1246                        });
1247                    }
1248                    // Use a small buffer + `char::encode_utf8` to avoid
1249                    // pulling in a heavyweight UTF-8 dependency. Invalid
1250                    // codepoints (surrogates) are silently skipped -
1251                    // regex-syntax shouldn't emit them in a parsed HIR
1252                    // for character classes, but the `char::from_u32`
1253                    // guard catches the corner case if it ever does.
1254                    if let Some(c) = char::from_u32(cp) {
1255                        let mut buf = [0u8; 4];
1256                        let encoded = c.encode_utf8(&mut buf);
1257                        sequences.push(encoded.as_bytes().to_vec());
1258                        budget -= 1;
1259                    }
1260                }
1261            }
1262        }
1263    }
1264    Ok(sequences)
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270
1271    fn states_of(s: &str) -> u32 {
1272        compile_regex_set(&[s]).unwrap().plan.num_states
1273    }
1274
1275    #[test]
1276    fn capture_mode_routing_splits_accelerator_from_verifier() {
1277        // Exactly the three whole-match modes run on the accelerator; the three
1278        // group-extraction modes require the verifier. Under this contract the
1279        // two bits are exact complements, assert both directions so a future
1280        // "neither" (unsupported) mode can't slip through as accelerator-eligible.
1281        for mode in CaptureMode::ALL {
1282            assert_eq!(
1283                mode.accelerator_eligible(),
1284                !mode.verifier_required(),
1285                "{mode:?}: accelerator_eligible must be the complement of verifier_required"
1286            );
1287        }
1288        let accel: Vec<CaptureMode> = CaptureMode::ALL
1289            .into_iter()
1290            .filter(|m| m.accelerator_eligible())
1291            .collect();
1292        assert_eq!(
1293            accel,
1294            vec![
1295                CaptureMode::NonCapture,
1296                CaptureMode::Count,
1297                CaptureMode::Span
1298            ],
1299            "only the whole-match modes are accelerator-eligible"
1300        );
1301    }
1302
1303    #[test]
1304    fn capture_mode_id_round_trips_and_is_unique() {
1305        use std::collections::BTreeSet;
1306        let mut ids = BTreeSet::new();
1307        for mode in CaptureMode::ALL {
1308            let id = mode.contract_row().mode_id;
1309            assert!(ids.insert(id), "duplicate mode_id `{id}`");
1310            assert_eq!(
1311                CaptureMode::from_mode_id(id),
1312                Some(mode),
1313                "mode_id `{id}` must round-trip back to {mode:?}"
1314            );
1315        }
1316        assert_eq!(ids.len(), 6, "all six modes must have distinct ids");
1317        assert_eq!(CaptureMode::from_mode_id("no_such_mode"), None);
1318    }
1319
1320    #[test]
1321    fn literal_compiles() {
1322        let r = compile_regex_set(&["abc"]).unwrap();
1323        // 1 entry + 1 literal-start + 3 letter states = 5
1324        assert_eq!(r.plan.num_states, 5);
1325        assert_eq!(r.plan.accept_states.len(), 1);
1326    }
1327
1328    #[test]
1329    fn alternation_compiles() {
1330        let r = compile_regex_set(&["a|b"]).unwrap();
1331        // entry + fork + join + 2*(start + 1 byte) = 1+1+1+2+2 = 7
1332        // (exact count depends on builder; just sanity-check it's >0).
1333        assert!(r.plan.num_states > 0);
1334        assert_eq!(r.plan.accept_states.len(), 1);
1335    }
1336
1337    #[test]
1338    fn class_compiles() {
1339        let r = compile_regex_set(&["[a-z]"]).unwrap();
1340        assert!(r.plan.num_states > 0);
1341        // Sanity: 26 lowercase bytes hit the same destination state.
1342        // We don't introspect the table here  -  just ensure it builds.
1343    }
1344
1345    #[test]
1346    fn text_anchors_compile_to_accept_flags() {
1347        let r = compile_regex_set(&["^foo$"]).unwrap();
1348        assert_eq!(r.plan.accept_start_anchored, vec![true]);
1349        assert_eq!(r.plan.accept_end_anchored, vec![true]);
1350    }
1351
1352    #[test]
1353    fn bounded_repetition_above_old_cap_compiles_under_state_cap() {
1354        let r = compile_regex_set(&["a{0,128}"]).unwrap();
1355        assert!(r.plan.num_states > 64);
1356        assert!(r.plan.num_states <= STATE_CAP as u32);
1357    }
1358
1359    #[test]
1360    fn regex_compile_preserves_accept_metadata_through_checked_paths() {
1361        let r = compile_regex_set(&["a", "bc", "^de$"]).unwrap();
1362
1363        assert_eq!(r.plan.accept_states, vec![(0, 1), (1, 2), (2, 2)]);
1364        assert_eq!(r.plan.accept_state_ids.len(), 3);
1365        assert_eq!(r.plan.accept_start_anchored, vec![false, false, true]);
1366        assert_eq!(r.plan.accept_end_anchored, vec![false, false, true]);
1367        assert_eq!(
1368            r.transition_table.len(),
1369            r.plan.num_states as usize * 256 * LANES
1370        );
1371        assert_eq!(r.epsilon_table.len(), r.plan.num_states as usize * LANES);
1372    }
1373
1374    #[test]
1375    fn regex_compile_uses_checked_abi_and_table_allocation_paths() {
1376        let production = include_str!("regex_compile.rs")
1377            .split("#[cfg(test)]")
1378            .next()
1379            .expect("Fix: regex_compile.rs must contain production section");
1380
1381        assert!(
1382            production.contains("u32::try_from(pid)")
1383                && production.contains("u32::try_from(frag.match_len)")
1384                && production.contains("u32::try_from(builder.state_count())")
1385                && production.contains("u32::try_from(self.state_count)")
1386                && production.contains("checked_add(1)")
1387                && production.contains("try_reserve_vec_to_capacity")
1388                && !production.contains("pid as u32")
1389                && !production.contains("frag.match_len as u32")
1390                && !production.contains("builder.state_count() as u32")
1391                && !production.contains("self.state_count as u32")
1392                && !production.contains("vec![0u32;")
1393                && !production.contains("Vec::with_capacity(patterns.len())"),
1394            "Fix: regex compilation must not truncate ids/counts or allocate NFA tables with infallible zero-vector construction."
1395        );
1396    }
1397
1398    #[test]
1399    fn regex_pipeline_uses_compiled_plan_instead_of_literal_source_plan() {
1400        let compiled = compile_regex_set(&["a|bc"]).unwrap();
1401        let pipeline = build_rule_pipeline_from_regex(&["a|bc"], "input", "hits", 64).unwrap();
1402
1403        assert_eq!(pipeline.plan.num_states, compiled.plan.num_states);
1404        assert_eq!(
1405            pipeline.plan.accept_state_ids,
1406            compiled.plan.accept_state_ids
1407        );
1408        assert_eq!(
1409            pipeline.epsilon_table.iter().any(|word| *word != 0),
1410            compiled.epsilon_table.iter().any(|word| *word != 0)
1411        );
1412        assert_ne!(
1413            pipeline.plan.num_states,
1414            crate::scan::nfa::compile(&["a|bc"]).num_states,
1415            "regex pipeline must not rebuild the scan program from literal regex source bytes"
1416        );
1417    }
1418
1419    #[test]
1420    fn states_count_grows_with_concat() {
1421        let one = states_of("a");
1422        let two = states_of("ab");
1423        let three = states_of("abc");
1424        assert!(two > one);
1425        assert!(three > two);
1426    }
1427
1428    #[test]
1429    fn state_cap_enforced() {
1430        // Build a regex that would exceed the per-pipeline state cap.
1431        // A literal of LANES*32+1 = 1025 chars exceeds the cap.
1432        let huge: String = (0..(STATE_CAP + 4)).map(|_| 'a').collect();
1433        let err = compile_regex_set(&[&huge]).unwrap_err();
1434        assert!(matches!(err, RegexCompileError::TooManyStates { .. }));
1435    }
1436
1437    #[test]
1438    fn unsupported_regex_diagnostic_does_not_route_to_cpu_backend() {
1439        let err = compile_regex_set(&[r"\bsecret\b"]).unwrap_err();
1440        let message = err.to_string().to_ascii_lowercase();
1441        assert!(
1442            !message.contains("cpu"),
1443            "unsupported GPU-NFA regex diagnostics must not recommend host-side routing: {message}"
1444        );
1445        assert!(
1446            message.contains("gpu"),
1447            "unsupported GPU-NFA regex diagnostics must name the GPU-compatible rewrite contract: {message}"
1448        );
1449    }
1450
1451    /// Contract: non-ASCII codepoints inside a character class no longer
1452    /// abort compile. They expand into a UTF-8 byte-sequence alternation
1453    /// the byte-NFA can represent. Mirrors the homoglyph-expanded
1454    /// detector patterns consumers feed in (e.g. openai `[hнһh]f_...`)
1455    /// that used to fall on the floor with "unicode character classes
1456    /// outside ASCII".
1457    #[test]
1458    fn unicode_class_outside_ascii_compiles_via_utf8_expansion() {
1459        // `н` (U+043D) and `һ` (U+04BB) are 2-byte UTF-8; `h` (U+FF48)
1460        // is 3-byte UTF-8; `h` (U+0068) is 1-byte. All four must be
1461        // representable.
1462        let pat = "[hнһh]f_[a-zA-Z0-9]{4}";
1463        let result = compile_regex_set(&[pat]);
1464        let compiled = match result {
1465            Ok(c) => c,
1466            Err(e) => {
1467                panic!("unicode-extended character class must compile via UTF-8 expansion; got {e}")
1468            }
1469        };
1470        // 4 alternation arms (one per codepoint) × varying byte length
1471        // + chain states + literal `f_` chain + bounded repetition
1472        // states - the exact count is implementation-dependent, but
1473        // every successfully-compiled regex must produce >=2 accept-
1474        // state-ids worth of state graph.
1475        assert!(
1476            compiled.plan.num_states > 4,
1477            "expanded NFA must have non-trivial state count"
1478        );
1479        // accept_state_ids carries one entry per accept (one pattern,
1480        // so one accept) regardless of arm count; the load-bearing
1481        // assertion is that compile didn't error.
1482        assert_eq!(compiled.plan.accept_states.len(), 1);
1483    }
1484
1485    /// Contract: classes containing ONLY ASCII still take the fast
1486    /// single-byte-transition path. Without this guarantee, every AC
1487    /// detector regex would pay the multi-state expansion cost.
1488    #[test]
1489    fn ascii_only_class_keeps_single_byte_transition_path() {
1490        // Single state for entry + 2 for `[ab]` (start + end) = 3.
1491        // Anything larger means we accidentally took the expansion arm.
1492        let r = compile_regex_set(&["[ab]"]).unwrap();
1493        assert_eq!(
1494            r.plan.num_states, 3,
1495            "[ab] must stay on the single-transition fast path (entry + 2 class states); got {} states",
1496            r.plan.num_states
1497        );
1498    }
1499
1500    /// Contract: massive Unicode ranges that would blow past the
1501    /// expansion cap return a structured error instead of consuming
1502    /// unbounded memory.
1503    #[test]
1504    fn unicode_class_above_expansion_cap_errors_cleanly() {
1505        // 257 codepoints - one above MAX_CLASS_EXPANSION_CODEPOINTS = 256.
1506        let pat = "[\u{0100}-\u{0200}]";
1507        let err = compile_regex_set(&[pat]).unwrap_err();
1508        match err {
1509            RegexCompileError::Unsupported { feature, .. } => {
1510                assert!(
1511                    feature.contains("expansion cap"),
1512                    "over-cap expansion must name the cap in its diagnostic: {feature}"
1513                );
1514            }
1515            other => panic!("expected Unsupported expansion-cap error, got {other:?}"),
1516        }
1517    }
1518
1519    /// The real compile path must emit the canonical registry diagnostic code for
1520    /// each construct the frontend distinctly identifies, so a consumer can route
1521    /// precisely (verifier vs reject) instead of parsing free-text `feature`.
1522    #[test]
1523    fn regex_compile_diagnostic_codes() {
1524        // A non-edge lookaround (word boundary) routes to the verifier.
1525        let look_err = compile_regex_set(&[r"a\bc"]).expect_err("word boundary is unsupported");
1526        assert_eq!(
1527            look_err.diagnostic_code(),
1528            Some("VYRE_SCAN_APPROXIMATED_LOOKAROUND_REQUIRES_VERIFIER"),
1529            "non-edge lookaround must map to its verifier diagnostic code; error was: {look_err}"
1530        );
1531
1532        // An over-cap Unicode class routes to the Unicode-mode-GPU rejection.
1533        let uni_err =
1534            compile_regex_set(&["[\u{0100}-\u{0200}]"]).expect_err("over-cap unicode class");
1535        assert_eq!(
1536            uni_err.diagnostic_code(),
1537            Some("VYRE_SCAN_UNSUPPORTED_UNICODE_MODE_GPU"),
1538            "over-cap unicode class must map to its diagnostic code; error was: {uni_err}"
1539        );
1540
1541        // Edge anchors are SUPPORTED (Look::Start/End), so no error at all.
1542        assert!(
1543            compile_regex_set(&["^abc$"]).is_ok(),
1544            "start/end anchors must compile, not be flagged as unsupported lookaround"
1545        );
1546
1547        // A pure syntax error is not a registry construct -> no code.
1548        let parse_err = compile_regex_set(&["("]).expect_err("unbalanced group is a parse error");
1549        assert_eq!(
1550            parse_err.diagnostic_code(),
1551            None,
1552            "a parse error must not claim a registry diagnostic code"
1553        );
1554
1555        // W2-3: a backreference is classified as its DISTINCT construct, not a
1556        // generic parse error, so a consumer can route on the registry code.
1557        let backref_err =
1558            compile_regex_set(&[r"(a)\1"]).expect_err("backreferences are unsupported");
1559        assert_eq!(
1560            backref_err.diagnostic_code(),
1561            Some("VYRE_SCAN_UNSUPPORTED_BACKREFERENCE"),
1562            "a backreference must map to its distinct code, not fall back to Parse; error was: {backref_err}"
1563        );
1564
1565        // W2-3: a huge alternation gets its own budget code instead of collapsing
1566        // into a generic TooManyStates.
1567        let huge: String = (0..(MAX_ALTERNATION_ARMS + 8))
1568            .map(|i| format!("v{i}"))
1569            .collect::<Vec<_>>()
1570            .join("|");
1571        let alt_err = compile_regex_set(&[huge.as_str()]).expect_err("over-budget alternation");
1572        assert_eq!(
1573            alt_err.diagnostic_code(),
1574            Some("VYRE_SCAN_UNSUPPORTED_HUGE_ALTERNATION_BUDGET"),
1575            "a huge alternation must map to its budget code, not TooManyStates; error was: {alt_err}"
1576        );
1577
1578        // W2-3: nested bounded repeats whose unroll product exceeds the budget get
1579        // their own code, distinct from a flat over-cap repeat.
1580        let nested_err =
1581            compile_regex_set(&[r"(?:a{40}){40}"]).expect_err("nested-repeat unroll blowup");
1582        assert_eq!(
1583            nested_err.diagnostic_code(),
1584            Some("VYRE_SCAN_UNSUPPORTED_NESTED_REPEAT_BUDGET"),
1585            "nested bounded repeats must map to their budget code; error was: {nested_err}"
1586        );
1587    }
1588
1589    /// The backreference detector must respect backslash escaping and match every
1590    /// backreference syntax `regex-syntax` rejects, WITHOUT string-matching parser
1591    /// error text (a structured source scan. ONE PLACE, no parse-message hacks).
1592    #[test]
1593    fn backreference_detector_is_escaping_aware() {
1594        // Numeric backreferences \1..\9 (in any position).
1595        assert!(pattern_uses_backreference(r"\1"));
1596        assert!(pattern_uses_backreference(r"(a)\1"));
1597        assert!(pattern_uses_backreference(r"foo\9bar"));
1598        // Named backreferences.
1599        assert!(pattern_uses_backreference(r"\k<name>"));
1600        assert!(pattern_uses_backreference(r"\k'name'"));
1601        assert!(pattern_uses_backreference("(?P=name)"));
1602
1603        // NOT backreferences: \0 is a NUL escape, an escaped backslash before a
1604        // digit is a literal backslash + literal digit, and ordinary escapes /
1605        // classes carry no backreference.
1606        assert!(!pattern_uses_backreference(r"\0"));
1607        assert!(
1608            !pattern_uses_backreference(r"\\1"),
1609            "an escaped backslash then a literal 1 is not a backreference"
1610        );
1611        assert!(!pattern_uses_backreference(r"\d+\w*"));
1612        assert!(!pattern_uses_backreference(r"[a-z]{3}"));
1613        assert!(!pattern_uses_backreference("plain text"));
1614        // `\\\1` = literal backslash, then a real backreference.
1615        assert!(pattern_uses_backreference(r"\\\1"));
1616    }
1617
1618    /// Capture groups must NOT become a compile error (whole-match acceleration
1619    /// still works); instead the compiled set reports capture presence so a
1620    /// consumer that needs submatch spans can route to the verifier.
1621    #[test]
1622    fn captures_compile_and_surface_the_verifier_diagnostic() {
1623        // A pattern with a capture group compiles (whole-match works) and reports
1624        // its presence + the verifier diagnostic code.
1625        let with_cap = compile_regex_set(&[r"(abc)def"]).expect("captures compile for whole-match");
1626        assert!(with_cap.captures_present, "the capture group must be noted");
1627        assert_eq!(
1628            with_cap.capture_extraction_diagnostic_code(),
1629            Some("VYRE_SCAN_CAPTURE_EXTRACTION_REQUIRES_VERIFIER"),
1630            "a captured pattern must surface the capture-verifier code without erroring"
1631        );
1632
1633        // A capture-free pattern compiles with no capture signal.
1634        let no_cap = compile_regex_set(&[r"abcdef"]).expect("plain pattern compiles");
1635        assert!(!no_cap.captures_present);
1636        assert_eq!(no_cap.capture_extraction_diagnostic_code(), None);
1637
1638        // A non-capturing group is not a capture.
1639        let noncap = compile_regex_set(&[r"(?:abc)def"]).expect("non-capturing group compiles");
1640        assert!(
1641            !noncap.captures_present,
1642            "a (?:…) non-capturing group must not be flagged as a capture"
1643        );
1644    }
1645
1646    /// SOUNDNESS / no-regression: the budget reclassification must only relabel
1647    /// patterns that ALREADY failed (state overflow). Patterns UNDER the budgets
1648    /// must still compile exactly as before.
1649    #[test]
1650    fn budget_reclassification_does_not_regress_compiling_patterns() {
1651        // A normal multi-arm alternation (well under both the arm budget AND the
1652        // state cap, each arm is a single byte) still compiles: the arm-count
1653        // check must not false-fire on ordinary alternations.
1654        let ok_alt: String = ('a'..='z')
1655            .chain('A'..='Z')
1656            .chain('0'..='9')
1657            .map(|c| c.to_string())
1658            .collect::<Vec<_>>()
1659            .join("|");
1660        let compiled = compile_regex_set(&[ok_alt.as_str()])
1661            .expect("a 62-arm single-byte alternation must still compile");
1662        // And it must NOT be misclassified as a huge alternation.
1663        assert!(compiled.plan.num_states > 0);
1664
1665        // A nested bounded repeat whose unroll product is under the budget still
1666        // compiles (20*20 = 400 < 1024).
1667        assert!(
1668            compile_regex_set(&[r"(?:a{20}){20}"]).is_ok(),
1669            "a nested repeat under the unroll budget must still compile"
1670        );
1671
1672        // The ONE-PLACE construct→code map round-trips every construct.
1673        assert_eq!(
1674            regex_construct_diagnostic_code(RegexConstruct::Backreference),
1675            "VYRE_SCAN_UNSUPPORTED_BACKREFERENCE"
1676        );
1677        assert_eq!(
1678            regex_construct_diagnostic_code(RegexConstruct::NestedRepeats),
1679            "VYRE_SCAN_UNSUPPORTED_NESTED_REPEAT_BUDGET"
1680        );
1681    }
1682
1683    /// W8-2 (structured diagnostics quality): every capability refusal must carry
1684    /// the `regex_compile:` owner prefix AND a `Fix:` clause naming the remedy
1685    /// the engineering standard that error messages include context and the fix.
1686    /// The `variants` array below is enforced COMPLETE by the exhaustive match in
1687    /// `assert_covers_every_variant`: adding a `RegexCompileError` variant without
1688    /// listing it here fails to COMPILE (the refusal cannot ship un-audited), and
1689    /// the per-variant assertions fail if any Display drops its owner or fix path.
1690    #[test]
1691    fn every_compile_error_variant_names_its_owner_and_fix_path() {
1692        let variants = [
1693            RegexCompileError::Parse {
1694                pattern_index: 0,
1695                message: "unclosed group".to_string(),
1696            },
1697            RegexCompileError::Unsupported {
1698                pattern_index: 1,
1699                feature: "backreference",
1700            },
1701            RegexCompileError::TooManyStates {
1702                states: 5_000,
1703                cap: 1_024,
1704            },
1705            RegexCompileError::PatternCountOverflow { count: usize::MAX },
1706            RegexCompileError::MatchLengthOverflow {
1707                pattern_index: 2,
1708                len: usize::MAX,
1709            },
1710            RegexCompileError::TableWordCountOverflow {
1711                table: "transition",
1712            },
1713            RegexCompileError::StorageReserveFailed {
1714                field: "epsilon",
1715                requested: 9,
1716                message: "allocator refused".to_string(),
1717            },
1718        ];
1719
1720        // Exhaustiveness guard: the match has no wildcard arm, so a new variant
1721        // breaks the build here until it is added to `variants` above and given a
1722        // fix path in `Display` (this test is in the defining crate, where a
1723        // `#[non_exhaustive]` enum can still be matched exhaustively).
1724        fn assert_covers_every_variant(error: &RegexCompileError) {
1725            match error {
1726                RegexCompileError::Parse { .. }
1727                | RegexCompileError::Unsupported { .. }
1728                | RegexCompileError::TooManyStates { .. }
1729                | RegexCompileError::PatternCountOverflow { .. }
1730                | RegexCompileError::MatchLengthOverflow { .. }
1731                | RegexCompileError::TableWordCountOverflow { .. }
1732                | RegexCompileError::StorageReserveFailed { .. } => {}
1733            }
1734        }
1735
1736        for error in &variants {
1737            assert_covers_every_variant(error);
1738            let rendered = error.to_string();
1739            assert!(
1740                rendered.starts_with("regex_compile:"),
1741                "a RegexCompileError variant lacks the `regex_compile:` owner prefix: {rendered}"
1742            );
1743            assert!(
1744                rendered.contains("Fix:"),
1745                "a RegexCompileError variant lacks a `Fix:` remedy clause: {rendered}"
1746            );
1747        }
1748    }
1749}