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