Skip to main content

riffdb_types/
long_pattern.rs

1//! ADR-0174 long-value exact wildcard semantics.
2
3use sha2::{Digest, Sha256};
4
5/// Maximum authoritative UTF-8 source bytes admitted by `long_pattern_v1`.
6pub const MAX_LONG_PATTERN_SOURCE_BYTES_V1: usize = 8_000;
7/// Frozen maximum expansion of an 8,000-byte source under `unicode_fold_v1`.
8pub const MAX_LONG_PATTERN_MATCHED_BYTES_V1: usize = 144_000;
9/// Maximum submitted pattern bytes.
10pub const MAX_LONG_PATTERN_QUERY_BYTES_V1: usize = 8_000;
11/// Maximum wildcard atoms after profile transformation.
12pub const MAX_LONG_PATTERN_ATOMS_V1: usize = 144_000;
13/// Maximum compiler-declared provider rows in one partition.
14pub const MAX_LONG_PATTERN_ROWS_PER_PARTITION_V1: u32 = 65_535;
15
16/// Complete independent resource bounds shared by contracts, providers and query plans.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct LongPatternBoundsV1 {
19    source_bytes: u32,
20    matched_bytes: u32,
21    rows: u32,
22    total_matched_bytes: u64,
23    distinct_grams: u32,
24    postings: u64,
25    postings_bytes: u64,
26    grams_per_row: u32,
27    pattern_bytes: u32,
28    wildcard_atoms: u32,
29    literal_runs: u32,
30    candidates: u32,
31    verification_bytes: u64,
32    results: u32,
33}
34
35impl LongPatternBoundsV1 {
36    /// Constructs one fully checked bound set.
37    #[allow(clippy::too_many_arguments)]
38    pub fn new(
39        source_bytes: u32,
40        matched_bytes: u32,
41        rows: u32,
42        total_matched_bytes: u64,
43        distinct_grams: u32,
44        postings: u64,
45        postings_bytes: u64,
46        grams_per_row: u32,
47        pattern_bytes: u32,
48        wildcard_atoms: u32,
49        literal_runs: u32,
50        candidates: u32,
51        verification_bytes: u64,
52        results: u32,
53    ) -> Result<Self, LongPatternBoundsErrorV1> {
54        let bounds = Self {
55            source_bytes,
56            matched_bytes,
57            rows,
58            total_matched_bytes,
59            distinct_grams,
60            postings,
61            postings_bytes,
62            grams_per_row,
63            pattern_bytes,
64            wildcard_atoms,
65            literal_runs,
66            candidates,
67            verification_bytes,
68            results,
69        };
70        if source_bytes == 0
71            || source_bytes as usize > MAX_LONG_PATTERN_SOURCE_BYTES_V1
72            || matched_bytes == 0
73            || matched_bytes as usize > MAX_LONG_PATTERN_MATCHED_BYTES_V1
74            || rows == 0
75            || rows > MAX_LONG_PATTERN_ROWS_PER_PARTITION_V1
76            || total_matched_bytes == 0
77            || distinct_grams == 0
78            || postings == 0
79            || postings_bytes == 0
80            || grams_per_row == 0
81            || pattern_bytes == 0
82            || pattern_bytes as usize > MAX_LONG_PATTERN_QUERY_BYTES_V1
83            || wildcard_atoms == 0
84            || wildcard_atoms as usize > MAX_LONG_PATTERN_ATOMS_V1
85            || literal_runs == 0
86            || candidates == 0
87            || candidates > rows
88            || verification_bytes == 0
89            || results == 0
90            || results > candidates
91        {
92            return Err(LongPatternBoundsErrorV1);
93        }
94        Ok(bounds)
95    }
96    /// Maximum source bytes.
97    #[must_use]
98    pub const fn source_bytes(self) -> u32 {
99        self.source_bytes
100    }
101    /// Maximum matched bytes per row.
102    #[must_use]
103    pub const fn matched_bytes(self) -> u32 {
104        self.matched_bytes
105    }
106    /// Maximum rows per partition.
107    #[must_use]
108    pub const fn rows(self) -> u32 {
109        self.rows
110    }
111    /// Maximum total retained matched bytes.
112    #[must_use]
113    pub const fn total_matched_bytes(self) -> u64 {
114        self.total_matched_bytes
115    }
116    /// Maximum distinct grams.
117    #[must_use]
118    pub const fn distinct_grams(self) -> u32 {
119        self.distinct_grams
120    }
121    /// Maximum posting entries.
122    #[must_use]
123    pub const fn postings(self) -> u64 {
124        self.postings
125    }
126    /// Maximum encoded posting bytes.
127    #[must_use]
128    pub const fn postings_bytes(self) -> u64 {
129        self.postings_bytes
130    }
131    /// Maximum grams retained per row.
132    #[must_use]
133    pub const fn grams_per_row(self) -> u32 {
134        self.grams_per_row
135    }
136    /// Maximum submitted pattern bytes.
137    #[must_use]
138    pub const fn pattern_bytes(self) -> u32 {
139        self.pattern_bytes
140    }
141    /// Maximum transformed wildcard atoms.
142    #[must_use]
143    pub const fn wildcard_atoms(self) -> u32 {
144        self.wildcard_atoms
145    }
146    /// Maximum literal runs in one wildcard pattern.
147    #[must_use]
148    pub const fn literal_runs(self) -> u32 {
149        self.literal_runs
150    }
151    /// Maximum candidates verified by one query.
152    #[must_use]
153    pub const fn candidates(self) -> u32 {
154        self.candidates
155    }
156    /// Maximum matched bytes verified by one query.
157    #[must_use]
158    pub const fn verification_bytes(self) -> u64 {
159        self.verification_bytes
160    }
161    /// Maximum released results.
162    #[must_use]
163    pub const fn results(self) -> u32 {
164        self.results
165    }
166}
167
168/// A bound set was zero, inconsistent, or above a fixed ceiling.
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub struct LongPatternBoundsErrorV1;
171
172/// Frozen matching profile.
173#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174#[repr(u8)]
175pub enum LongPatternProfileV1 {
176    /// Compare canonical UTF-8 directly.
177    BinaryUtf8V1 = 1,
178    /// Apply the frozen Unicode fold before comparison.
179    UnicodeFoldV1 = 2,
180}
181
182impl LongPatternProfileV1 {
183    /// Decodes a stable profile discriminant.
184    #[must_use]
185    pub const fn from_discriminant(value: u8) -> Option<Self> {
186        match value {
187            1 => Some(Self::BinaryUtf8V1),
188            2 => Some(Self::UnicodeFoldV1),
189            _ => None,
190        }
191    }
192
193    /// Produces the exact retained matched form.
194    #[must_use]
195    pub fn matched_form(self, value: &str) -> String {
196        match self {
197            Self::BinaryUtf8V1 => value.to_owned(),
198            Self::UnicodeFoldV1 => crate::unicode_fold_v1(value),
199        }
200    }
201}
202
203/// Closed public operator inventory.
204#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
205#[repr(u8)]
206pub enum LongPatternOperatorV1 {
207    /// Whole-value equality under the declared profile.
208    Equals = 1,
209    /// Literal prefix under the declared profile.
210    StartsWith = 2,
211    /// Literal suffix under the declared profile.
212    EndsWith = 3,
213    /// Literal substring under the declared profile.
214    Contains = 4,
215    /// SQL-like `%` and `_` matching under binary UTF-8.
216    Like = 5,
217    /// SQL-like matching under `unicode_fold_v1`.
218    ILike = 6,
219    /// Authorized-universe difference against `Like`.
220    NotLike = 7,
221    /// Authorized-universe difference against `ILike`.
222    NotILike = 8,
223    /// Linear-time Thompson NFA regular expression matching (`ADR-0339`).
224    MatchesRegex = 9,
225}
226
227/// Maximum regular-expression pattern bytes admitted by `MatchesRegex` (`ADR-0339`).
228pub const MAX_REGEX_PATTERN_BYTES_V1: usize = 1_024;
229/// Maximum Thompson NFA states admitted for one regular expression (`ADR-0339`).
230pub const MAX_REGEX_NFA_STATES_V1: usize = 256;
231
232impl LongPatternOperatorV1 {
233    /// Decodes a stable operator discriminant.
234    #[must_use]
235    pub const fn from_discriminant(value: u8) -> Option<Self> {
236        match value {
237            1 => Some(Self::Equals),
238            2 => Some(Self::StartsWith),
239            3 => Some(Self::EndsWith),
240            4 => Some(Self::Contains),
241            5 => Some(Self::Like),
242            6 => Some(Self::ILike),
243            7 => Some(Self::NotLike),
244            8 => Some(Self::NotILike),
245            9 => Some(Self::MatchesRegex),
246            _ => None,
247        }
248    }
249
250    /// Whether the operator interprets wildcard syntax.
251    #[must_use]
252    pub const fn is_wildcard(self) -> bool {
253        matches!(
254            self,
255            Self::Like | Self::ILike | Self::NotLike | Self::NotILike
256        )
257    }
258
259    /// Whether the operator compiles a regular expression to a Thompson NFA.
260    #[must_use]
261    pub const fn is_regex(self) -> bool {
262        matches!(self, Self::MatchesRegex)
263    }
264
265    /// Whether matching uses the Unicode-fold profile regardless of storage profile.
266    #[must_use]
267    pub const fn is_case_insensitive(self) -> bool {
268        matches!(self, Self::ILike | Self::NotILike)
269    }
270
271    /// Whether result formation is an authorized positive-universe difference.
272    #[must_use]
273    pub const fn is_negated(self) -> bool {
274        matches!(self, Self::NotLike | Self::NotILike)
275    }
276}
277
278/// One compiled wildcard atom. This is process-local semantic state, not a durable codec.
279#[derive(Clone, Copy, Debug, Eq, PartialEq)]
280enum PatternAtomV1 {
281    Literal(char),
282    One,
283    Many,
284}
285
286#[derive(Clone, Debug, Eq, PartialEq)]
287enum RegexCharMatcherV1 {
288    Exact(char),
289    Any,
290    Digit,
291    Word,
292    Space,
293    Class {
294        negated: bool,
295        ranges: Vec<(char, char)>,
296    },
297}
298
299impl RegexCharMatcherV1 {
300    fn matches(&self, ch: char) -> bool {
301        match self {
302            Self::Exact(expected) => *expected == ch,
303            Self::Any => ch != '\n',
304            Self::Digit => ch.is_ascii_digit(),
305            Self::Word => ch.is_ascii_alphanumeric() || ch == '_',
306            Self::Space => ch.is_whitespace(),
307            Self::Class { negated, ranges } => {
308                let hit = ranges.iter().any(|(start, end)| *start <= ch && ch <= *end);
309                if *negated { !hit } else { hit }
310            }
311        }
312    }
313}
314
315#[derive(Clone, Debug, Eq, PartialEq)]
316enum ThompsonStateV1 {
317    Match {
318        matcher: RegexCharMatcherV1,
319        next: usize,
320    },
321    Split {
322        out1: usize,
323        out2: usize,
324    },
325    Jump {
326        target: usize,
327    },
328    Accept,
329}
330
331/// Backtracking-free Thompson NFA compiled from a bounded regular expression (`ADR-0339`).
332#[derive(Clone, Debug, Eq, PartialEq)]
333struct CompiledThompsonNfaV1 {
334    anchored_start: bool,
335    anchored_end: bool,
336    start_state: usize,
337    states: Vec<ThompsonStateV1>,
338}
339
340/// Checked pattern with bounded transformed atoms and mandatory gram hints.
341#[derive(Clone, Debug, Eq, PartialEq)]
342pub struct CompiledLongPatternV1 {
343    operator: LongPatternOperatorV1,
344    profile: LongPatternProfileV1,
345    literal: Option<String>,
346    atoms: Vec<PatternAtomV1>,
347    regex_nfa: Option<CompiledThompsonNfaV1>,
348    mandatory_grams: Vec<[u8; 3]>,
349}
350
351impl CompiledLongPatternV1 {
352    /// Compiles one bounded literal or wildcard pattern.
353    pub fn compile(
354        operator: LongPatternOperatorV1,
355        declared_profile: LongPatternProfileV1,
356        source: &str,
357    ) -> Result<Self, LongPatternErrorV1> {
358        Self::compile_with_limits(
359            operator,
360            declared_profile,
361            source,
362            MAX_LONG_PATTERN_QUERY_BYTES_V1,
363            MAX_LONG_PATTERN_MATCHED_BYTES_V1,
364            MAX_LONG_PATTERN_ATOMS_V1,
365            MAX_LONG_PATTERN_ATOMS_V1,
366        )
367    }
368
369    /// Compiles against the exact immutable bounds declared by one provider.
370    pub fn compile_bounded(
371        operator: LongPatternOperatorV1,
372        declared_profile: LongPatternProfileV1,
373        source: &str,
374        bounds: LongPatternBoundsV1,
375    ) -> Result<Self, LongPatternErrorV1> {
376        Self::compile_with_limits(
377            operator,
378            declared_profile,
379            source,
380            bounds.pattern_bytes() as usize,
381            bounds.matched_bytes() as usize,
382            bounds.wildcard_atoms() as usize,
383            bounds.literal_runs() as usize,
384        )
385    }
386
387    fn compile_with_limits(
388        operator: LongPatternOperatorV1,
389        declared_profile: LongPatternProfileV1,
390        source: &str,
391        pattern_bytes: usize,
392        matched_bytes: usize,
393        wildcard_atoms: usize,
394        literal_runs: usize,
395    ) -> Result<Self, LongPatternErrorV1> {
396        if source.is_empty() || source.len() > pattern_bytes {
397            return Err(LongPatternErrorV1::PatternBytes);
398        }
399        let profile = if operator.is_case_insensitive() {
400            LongPatternProfileV1::UnicodeFoldV1
401        } else {
402            declared_profile
403        };
404        if operator.is_regex() {
405            if source.len() > MAX_REGEX_PATTERN_BYTES_V1 {
406                return Err(LongPatternErrorV1::PatternBytes);
407            }
408            let (nfa, mandatory_grams) = compile_thompson_regex_v1(source, profile)?;
409            return Ok(Self {
410                operator,
411                profile,
412                literal: None,
413                atoms: Vec::new(),
414                regex_nfa: Some(nfa),
415                mandatory_grams,
416            });
417        }
418        if !operator.is_wildcard() {
419            let literal = profile.matched_form(source);
420            if literal.len() > matched_bytes {
421                return Err(LongPatternErrorV1::MatchedBytes);
422            }
423            let mandatory_grams = distinct_grams(literal.as_bytes());
424            return Ok(Self {
425                operator,
426                profile,
427                literal: Some(literal),
428                atoms: Vec::new(),
429                regex_nfa: None,
430                mandatory_grams,
431            });
432        }
433
434        let runs = parse_wildcard_runs(source)?;
435        if runs.len() > literal_runs {
436            return Err(LongPatternErrorV1::PatternAtoms);
437        }
438        let mut atoms = Vec::new();
439        let mut mandatory_grams = Vec::new();
440        for run in runs {
441            match run {
442                PatternRunV1::Literal(value) => {
443                    let matched = profile.matched_form(&value);
444                    mandatory_grams.extend(distinct_grams(matched.as_bytes()));
445                    atoms.extend(matched.chars().map(PatternAtomV1::Literal));
446                }
447                PatternRunV1::One => atoms.push(PatternAtomV1::One),
448                PatternRunV1::Many => {
449                    if !matches!(atoms.last(), Some(PatternAtomV1::Many)) {
450                        atoms.push(PatternAtomV1::Many);
451                    }
452                }
453            }
454            if atoms.len() > wildcard_atoms {
455                return Err(LongPatternErrorV1::PatternAtoms);
456            }
457        }
458        mandatory_grams.sort_unstable();
459        mandatory_grams.dedup();
460        Ok(Self {
461            operator,
462            profile,
463            literal: None,
464            atoms,
465            regex_nfa: None,
466            mandatory_grams,
467        })
468    }
469
470    /// Exact operator selected by the compiled query.
471    #[must_use]
472    pub const fn operator(&self) -> LongPatternOperatorV1 {
473        self.operator
474    }
475
476    /// Effective matching profile.
477    #[must_use]
478    pub const fn profile(&self) -> LongPatternProfileV1 {
479        self.profile
480    }
481
482    /// Ordered distinct mandatory literal grams. Empty means bounded provider scan.
483    #[must_use]
484    pub fn mandatory_grams(&self) -> &[[u8; 3]] {
485        &self.mandatory_grams
486    }
487
488    /// Verifies one already-profiled retained value exactly.
489    #[must_use]
490    pub fn matches_matched(&self, matched: &str) -> bool {
491        if let Some(ref nfa) = self.regex_nfa {
492            return nfa.find_first_span(matched).is_some();
493        }
494        let positive = match (self.operator, self.literal.as_deref()) {
495            (LongPatternOperatorV1::Equals, Some(literal)) => matched == literal,
496            (LongPatternOperatorV1::StartsWith, Some(literal)) => matched.starts_with(literal),
497            (LongPatternOperatorV1::EndsWith, Some(literal)) => matched.ends_with(literal),
498            (LongPatternOperatorV1::Contains, Some(literal)) => matched.contains(literal),
499            _ => wildcard_matches(&self.atoms, matched),
500        };
501        if self.operator.is_negated() {
502            !positive
503        } else {
504            positive
505        }
506    }
507
508    /// Finds the first matched byte span `(start_byte, end_byte)` in an already-profiled string.
509    #[must_use]
510    pub fn find_first_match_span(&self, matched: &str) -> Option<(usize, usize)> {
511        if let Some(ref nfa) = self.regex_nfa {
512            return nfa.find_first_span(matched);
513        }
514        match (self.operator, self.literal.as_deref()) {
515            (LongPatternOperatorV1::Equals, Some(literal)) if matched == literal => {
516                Some((0, matched.len()))
517            }
518            (LongPatternOperatorV1::StartsWith, Some(literal)) if matched.starts_with(literal) => {
519                Some((0, literal.len()))
520            }
521            (LongPatternOperatorV1::EndsWith, Some(literal)) if matched.ends_with(literal) => {
522                Some((matched.len() - literal.len(), matched.len()))
523            }
524            (LongPatternOperatorV1::Contains, Some(literal)) => matched
525                .find(literal)
526                .map(|start| (start, start + literal.len())),
527            _ => {
528                if self.matches_matched(matched) {
529                    Some((0, matched.len()))
530                } else {
531                    None
532                }
533            }
534        }
535    }
536
537    /// Applies the effective profile and verifies one source value.
538    #[must_use]
539    pub fn matches_source(&self, source: &str) -> bool {
540        self.matches_matched(&self.profile.matched_form(source))
541    }
542}
543
544#[derive(Clone, Debug, Eq, PartialEq)]
545enum PatternRunV1 {
546    Literal(String),
547    One,
548    Many,
549}
550
551fn parse_wildcard_runs(source: &str) -> Result<Vec<PatternRunV1>, LongPatternErrorV1> {
552    let mut runs = Vec::new();
553    let mut literal = String::new();
554    let mut chars = source.chars();
555    while let Some(character) = chars.next() {
556        match character {
557            '\\' => {
558                let escaped = chars.next().ok_or(LongPatternErrorV1::InvalidEscape)?;
559                if !matches!(escaped, '%' | '_' | '\\') {
560                    return Err(LongPatternErrorV1::InvalidEscape);
561                }
562                literal.push(escaped);
563            }
564            '%' | '_' => {
565                if !literal.is_empty() {
566                    runs.push(PatternRunV1::Literal(std::mem::take(&mut literal)));
567                }
568                runs.push(if character == '%' {
569                    PatternRunV1::Many
570                } else {
571                    PatternRunV1::One
572                });
573            }
574            _ => literal.push(character),
575        }
576    }
577    if !literal.is_empty() {
578        runs.push(PatternRunV1::Literal(literal));
579    }
580    Ok(runs)
581}
582
583fn wildcard_matches(atoms: &[PatternAtomV1], matched: &str) -> bool {
584    let value = matched.chars().collect::<Vec<_>>();
585    let (mut atom, mut scalar) = (0_usize, 0_usize);
586    let (mut star, mut star_scalar) = (None, 0_usize);
587    while scalar < value.len() {
588        match atoms.get(atom) {
589            Some(PatternAtomV1::Literal(expected)) if *expected == value[scalar] => {
590                atom += 1;
591                scalar += 1;
592            }
593            Some(PatternAtomV1::One) => {
594                atom += 1;
595                scalar += 1;
596            }
597            Some(PatternAtomV1::Many) => {
598                star = Some(atom);
599                atom += 1;
600                star_scalar = scalar;
601            }
602            _ => {
603                let Some(star_atom) = star else {
604                    return false;
605                };
606                star_scalar += 1;
607                scalar = star_scalar;
608                atom = star_atom + 1;
609            }
610        }
611    }
612    while matches!(atoms.get(atom), Some(PatternAtomV1::Many)) {
613        atom += 1;
614    }
615    atom == atoms.len()
616}
617
618#[derive(Clone, Debug)]
619enum RegexAstV1 {
620    Empty,
621    Matcher(RegexCharMatcherV1),
622    Seq(Vec<RegexAstV1>),
623    Alt(Vec<RegexAstV1>),
624    Repeat {
625        inner: Box<RegexAstV1>,
626        min: usize,
627        max: Option<usize>,
628    },
629}
630
631fn compile_thompson_regex_v1(
632    source: &str,
633    profile: LongPatternProfileV1,
634) -> Result<(CompiledThompsonNfaV1, Vec<[u8; 3]>), LongPatternErrorV1> {
635    let mut trimmed = source;
636    let anchored_start = if let Some(rest) = trimmed.strip_prefix('^') {
637        trimmed = rest;
638        true
639    } else {
640        false
641    };
642    let anchored_end = if trimmed.ends_with('$') && !trimmed.ends_with(r"\$") {
643        trimmed = &trimmed[..trimmed.len() - 1];
644        true
645    } else {
646        false
647    };
648    let chars = trimmed.chars().collect::<Vec<_>>();
649    let mut pos = 0_usize;
650    let ast = parse_regex_alt(&chars, &mut pos, profile)?;
651    if pos != chars.len() {
652        return Err(LongPatternErrorV1::InvalidRegex);
653    }
654    let mandatory_grams = extract_mandatory_grams_from_ast(&ast);
655    let mut builder = ThompsonBuilderV1 { states: Vec::new() };
656    let accept = builder.push(ThompsonStateV1::Accept)?;
657    let start_state = builder.compile_node(&ast, accept)?;
658    Ok((
659        CompiledThompsonNfaV1 {
660            anchored_start,
661            anchored_end,
662            start_state,
663            states: builder.states,
664        },
665        mandatory_grams,
666    ))
667}
668
669fn parse_regex_alt(
670    chars: &[char],
671    pos: &mut usize,
672    profile: LongPatternProfileV1,
673) -> Result<RegexAstV1, LongPatternErrorV1> {
674    let mut branches = vec![parse_regex_seq(chars, pos, profile)?];
675    while *pos < chars.len() && chars[*pos] == '|' {
676        *pos += 1;
677        branches.push(parse_regex_seq(chars, pos, profile)?);
678    }
679    if branches.len() == 1 {
680        Ok(branches.pop().unwrap_or(RegexAstV1::Empty))
681    } else {
682        Ok(RegexAstV1::Alt(branches))
683    }
684}
685
686fn parse_regex_seq(
687    chars: &[char],
688    pos: &mut usize,
689    profile: LongPatternProfileV1,
690) -> Result<RegexAstV1, LongPatternErrorV1> {
691    let mut items = Vec::new();
692    while *pos < chars.len() && !matches!(chars[*pos], '|' | ')') {
693        let atom = parse_regex_atom(chars, pos, profile)?;
694        let quantified = parse_regex_quantifier(chars, pos, atom)?;
695        items.push(quantified);
696    }
697    if items.is_empty() {
698        Ok(RegexAstV1::Empty)
699    } else if items.len() == 1 {
700        Ok(items.pop().unwrap_or(RegexAstV1::Empty))
701    } else {
702        Ok(RegexAstV1::Seq(items))
703    }
704}
705
706fn parse_regex_atom(
707    chars: &[char],
708    pos: &mut usize,
709    profile: LongPatternProfileV1,
710) -> Result<RegexAstV1, LongPatternErrorV1> {
711    let Some(&ch) = chars.get(*pos) else {
712        return Err(LongPatternErrorV1::InvalidRegex);
713    };
714    match ch {
715        '(' => {
716            *pos += 1;
717            let inner = parse_regex_alt(chars, pos, profile)?;
718            if chars.get(*pos) != Some(&')') {
719                return Err(LongPatternErrorV1::InvalidRegex);
720            }
721            *pos += 1;
722            Ok(inner)
723        }
724        '[' => {
725            *pos += 1;
726            let negated = if chars.get(*pos) == Some(&'^') {
727                *pos += 1;
728                true
729            } else {
730                false
731            };
732            let mut ranges = Vec::new();
733            while *pos < chars.len() && chars[*pos] != ']' {
734                let start = parse_class_char(chars, pos, profile)?;
735                if chars.get(*pos) == Some(&'-')
736                    && chars.get(*pos + 1).is_some_and(|&next| next != ']')
737                {
738                    *pos += 1;
739                    let end = parse_class_char(chars, pos, profile)?;
740                    if start > end {
741                        return Err(LongPatternErrorV1::InvalidRegex);
742                    }
743                    ranges.push((start, end));
744                } else {
745                    ranges.push((start, start));
746                }
747            }
748            if chars.get(*pos) != Some(&']') || ranges.is_empty() {
749                return Err(LongPatternErrorV1::InvalidRegex);
750            }
751            *pos += 1;
752            Ok(RegexAstV1::Matcher(RegexCharMatcherV1::Class {
753                negated,
754                ranges,
755            }))
756        }
757        '.' => {
758            *pos += 1;
759            Ok(RegexAstV1::Matcher(RegexCharMatcherV1::Any))
760        }
761        '\\' => {
762            *pos += 1;
763            let Some(&escaped) = chars.get(*pos) else {
764                return Err(LongPatternErrorV1::InvalidRegex);
765            };
766            *pos += 1;
767            match escaped {
768                'd' => Ok(RegexAstV1::Matcher(RegexCharMatcherV1::Digit)),
769                'w' => Ok(RegexAstV1::Matcher(RegexCharMatcherV1::Word)),
770                's' => Ok(RegexAstV1::Matcher(RegexCharMatcherV1::Space)),
771                '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$'
772                | '\\' | '-' => Ok(literal_char_ast(escaped, profile)),
773                _ => Err(LongPatternErrorV1::InvalidRegex),
774            }
775        }
776        '*' | '+' | '?' | '{' | '}' | '^' | '$' => Err(LongPatternErrorV1::InvalidRegex),
777        literal => {
778            *pos += 1;
779            Ok(literal_char_ast(literal, profile))
780        }
781    }
782}
783
784fn literal_char_ast(ch: char, profile: LongPatternProfileV1) -> RegexAstV1 {
785    let mut buf = [0_u8; 4];
786    let s = ch.encode_utf8(&mut buf);
787    let folded = profile.matched_form(s);
788    let mut mapped = folded
789        .chars()
790        .map(|c| RegexAstV1::Matcher(RegexCharMatcherV1::Exact(c)))
791        .collect::<Vec<_>>();
792    if mapped.len() == 1 {
793        mapped.pop().unwrap_or(RegexAstV1::Empty)
794    } else {
795        RegexAstV1::Seq(mapped)
796    }
797}
798
799fn parse_class_char(
800    chars: &[char],
801    pos: &mut usize,
802    profile: LongPatternProfileV1,
803) -> Result<char, LongPatternErrorV1> {
804    let Some(&ch) = chars.get(*pos) else {
805        return Err(LongPatternErrorV1::InvalidRegex);
806    };
807    *pos += 1;
808    let raw = if ch == '\\' {
809        let Some(&esc) = chars.get(*pos) else {
810            return Err(LongPatternErrorV1::InvalidRegex);
811        };
812        *pos += 1;
813        esc
814    } else {
815        ch
816    };
817    let mut buf = [0_u8; 4];
818    let folded = profile.matched_form(raw.encode_utf8(&mut buf));
819    let mut iter = folded.chars();
820    let first = iter.next().ok_or(LongPatternErrorV1::InvalidRegex)?;
821    if iter.next().is_some() {
822        return Err(LongPatternErrorV1::InvalidRegex);
823    }
824    Ok(first)
825}
826
827fn parse_regex_quantifier(
828    chars: &[char],
829    pos: &mut usize,
830    atom: RegexAstV1,
831) -> Result<RegexAstV1, LongPatternErrorV1> {
832    match chars.get(*pos) {
833        Some('*') => {
834            *pos += 1;
835            Ok(RegexAstV1::Repeat {
836                inner: Box::new(atom),
837                min: 0,
838                max: None,
839            })
840        }
841        Some('+') => {
842            *pos += 1;
843            Ok(RegexAstV1::Repeat {
844                inner: Box::new(atom),
845                min: 1,
846                max: None,
847            })
848        }
849        Some('?') => {
850            *pos += 1;
851            Ok(RegexAstV1::Repeat {
852                inner: Box::new(atom),
853                min: 0,
854                max: Some(1),
855            })
856        }
857        Some('{') => {
858            *pos += 1;
859            let min = parse_bounded_decimal(chars, pos)?;
860            let max = if chars.get(*pos) == Some(&',') {
861                *pos += 1;
862                if chars.get(*pos) == Some(&'}') {
863                    None
864                } else {
865                    Some(parse_bounded_decimal(chars, pos)?)
866                }
867            } else {
868                Some(min)
869            };
870            if chars.get(*pos) != Some(&'}') {
871                return Err(LongPatternErrorV1::InvalidRegex);
872            }
873            *pos += 1;
874            if min > 32 || max.is_some_and(|m| m < min || m > 32) {
875                return Err(LongPatternErrorV1::PatternAtoms);
876            }
877            Ok(RegexAstV1::Repeat {
878                inner: Box::new(atom),
879                min,
880                max,
881            })
882        }
883        _ => Ok(atom),
884    }
885}
886
887fn parse_bounded_decimal(chars: &[char], pos: &mut usize) -> Result<usize, LongPatternErrorV1> {
888    let start = *pos;
889    let mut value = 0_usize;
890    while let Some(&ch) = chars.get(*pos) {
891        if let Some(digit) = ch.to_digit(10) {
892            value = value
893                .checked_mul(10)
894                .and_then(|v| v.checked_add(digit as usize))
895                .ok_or(LongPatternErrorV1::PatternAtoms)?;
896            *pos += 1;
897        } else {
898            break;
899        }
900    }
901    if *pos == start {
902        return Err(LongPatternErrorV1::InvalidRegex);
903    }
904    Ok(value)
905}
906
907fn extract_mandatory_grams_from_ast(ast: &RegexAstV1) -> Vec<[u8; 3]> {
908    let mut grams = Vec::new();
909    match ast {
910        RegexAstV1::Seq(items) => {
911            let mut current_literal = String::new();
912            for item in items {
913                match item {
914                    RegexAstV1::Matcher(RegexCharMatcherV1::Exact(ch)) => {
915                        current_literal.push(*ch);
916                    }
917                    RegexAstV1::Repeat {
918                        inner, min: 1.., ..
919                    } => {
920                        if let RegexAstV1::Matcher(RegexCharMatcherV1::Exact(ch)) = inner.as_ref() {
921                            current_literal.push(*ch);
922                            grams.extend(distinct_grams(current_literal.as_bytes()));
923                            current_literal.clear();
924                        } else {
925                            grams.extend(distinct_grams(current_literal.as_bytes()));
926                            current_literal.clear();
927                            grams.extend(extract_mandatory_grams_from_ast(inner));
928                        }
929                    }
930                    other => {
931                        grams.extend(distinct_grams(current_literal.as_bytes()));
932                        current_literal.clear();
933                        grams.extend(extract_mandatory_grams_from_ast(other));
934                    }
935                }
936            }
937            grams.extend(distinct_grams(current_literal.as_bytes()));
938        }
939        RegexAstV1::Alt(branches) => {
940            if let Some((first, rest)) = branches.split_first() {
941                let mut common = extract_mandatory_grams_from_ast(first);
942                for branch in rest {
943                    let branch_grams = extract_mandatory_grams_from_ast(branch);
944                    common.retain(|gram| branch_grams.contains(gram));
945                }
946                grams.extend(common);
947            }
948        }
949        RegexAstV1::Repeat {
950            inner, min: 1.., ..
951        } => {
952            grams.extend(extract_mandatory_grams_from_ast(inner));
953        }
954        _ => {}
955    }
956    grams.sort_unstable();
957    grams.dedup();
958    grams
959}
960
961struct ThompsonBuilderV1 {
962    states: Vec<ThompsonStateV1>,
963}
964
965impl ThompsonBuilderV1 {
966    fn push(&mut self, state: ThompsonStateV1) -> Result<usize, LongPatternErrorV1> {
967        if self.states.len() >= MAX_REGEX_NFA_STATES_V1 {
968            return Err(LongPatternErrorV1::PatternAtoms);
969        }
970        let id = self.states.len();
971        self.states.push(state);
972        Ok(id)
973    }
974
975    fn compile_node(&mut self, ast: &RegexAstV1, out: usize) -> Result<usize, LongPatternErrorV1> {
976        match ast {
977            RegexAstV1::Empty => Ok(out),
978            RegexAstV1::Matcher(matcher) => self.push(ThompsonStateV1::Match {
979                matcher: matcher.clone(),
980                next: out,
981            }),
982            RegexAstV1::Seq(items) => {
983                let mut next = out;
984                for item in items.iter().rev() {
985                    next = self.compile_node(item, next)?;
986                }
987                Ok(next)
988            }
989            RegexAstV1::Alt(branches) => {
990                let mut iter = branches.iter().rev();
991                let Some(last) = iter.next() else {
992                    return Ok(out);
993                };
994                let mut current = self.compile_node(last, out)?;
995                for branch in iter {
996                    let left = self.compile_node(branch, out)?;
997                    current = self.push(ThompsonStateV1::Split {
998                        out1: left,
999                        out2: current,
1000                    })?;
1001                }
1002                Ok(current)
1003            }
1004            RegexAstV1::Repeat { inner, min, max } => {
1005                let tail = match max {
1006                    None => {
1007                        let split_id = self.push(ThompsonStateV1::Jump { target: out })?;
1008                        let body = self.compile_node(inner, split_id)?;
1009                        self.states[split_id] = ThompsonStateV1::Split {
1010                            out1: body,
1011                            out2: out,
1012                        };
1013                        split_id
1014                    }
1015                    Some(max_val) => {
1016                        let mut next = out;
1017                        for _ in *min..*max_val {
1018                            let body = self.compile_node(inner, next)?;
1019                            next = self.push(ThompsonStateV1::Split {
1020                                out1: body,
1021                                out2: next,
1022                            })?;
1023                        }
1024                        next
1025                    }
1026                };
1027                let mut current = tail;
1028                for _ in 0..*min {
1029                    current = self.compile_node(inner, current)?;
1030                }
1031                Ok(current)
1032            }
1033        }
1034    }
1035}
1036
1037impl CompiledThompsonNfaV1 {
1038    /// Linear-time single-pass Thompson NFA simulation with zero backtracking (`ADR-0339`).
1039    fn find_first_span(&self, text: &str) -> Option<(usize, usize)> {
1040        let mut current: Vec<Option<usize>> = vec![None; self.states.len()];
1041        let mut next: Vec<Option<usize>> = vec![None; self.states.len()];
1042        let mut stack: Vec<usize> = Vec::with_capacity(self.states.len());
1043        let mut best_match: Option<(usize, usize)> = None;
1044
1045        let record_accepts = |active: &[Option<usize>],
1046                              end_byte: usize,
1047                              at_end: bool,
1048                              best: &mut Option<(usize, usize)>| {
1049            if self.anchored_end && !at_end {
1050                return;
1051            }
1052            for (idx, start_opt) in active.iter().enumerate() {
1053                if let Some(start_byte) = *start_opt
1054                    && matches!(self.states[idx], ThompsonStateV1::Accept)
1055                {
1056                    match *best {
1057                        None => *best = Some((start_byte, end_byte)),
1058                        Some((best_start, best_end)) => {
1059                            if start_byte < best_start
1060                                || (start_byte == best_start && end_byte > best_end)
1061                            {
1062                                *best = Some((start_byte, end_byte));
1063                            }
1064                        }
1065                    }
1066                }
1067            }
1068        };
1069
1070        for (pos, (here_byte, ch)) in text.char_indices().enumerate() {
1071            if (pos == 0 || !self.anchored_start) && best_match.is_none() {
1072                self.add_epsilon_closure_tagged(
1073                    self.start_state,
1074                    here_byte,
1075                    &mut current,
1076                    &mut stack,
1077                );
1078            }
1079            record_accepts(&current, here_byte, false, &mut best_match);
1080
1081            next.fill(None);
1082            let mut any_active = false;
1083            for (state_id, start_opt) in current.iter().enumerate() {
1084                let Some(start_byte) = *start_opt else {
1085                    continue;
1086                };
1087                if let Some((best_start, _)) = best_match
1088                    && start_byte > best_start
1089                {
1090                    continue;
1091                }
1092                if let ThompsonStateV1::Match { matcher, next: dst } = &self.states[state_id]
1093                    && matcher.matches(ch)
1094                {
1095                    self.add_epsilon_closure_tagged(*dst, start_byte, &mut next, &mut stack);
1096                    any_active = true;
1097                }
1098            }
1099            std::mem::swap(&mut current, &mut next);
1100            if !any_active && (self.anchored_start || best_match.is_some()) {
1101                break;
1102            }
1103        }
1104
1105        let end_byte = text.len();
1106        if (text.is_empty() || !self.anchored_start) && best_match.is_none() {
1107            self.add_epsilon_closure_tagged(self.start_state, end_byte, &mut current, &mut stack);
1108        }
1109        record_accepts(&current, end_byte, true, &mut best_match);
1110        best_match
1111    }
1112
1113    fn add_epsilon_closure_tagged(
1114        &self,
1115        start: usize,
1116        start_byte: usize,
1117        active: &mut [Option<usize>],
1118        stack: &mut Vec<usize>,
1119    ) {
1120        stack.clear();
1121        stack.push(start);
1122        while let Some(state_id) = stack.pop() {
1123            if let Some(existing_start) = active[state_id]
1124                && existing_start <= start_byte
1125            {
1126                continue;
1127            }
1128            active[state_id] = Some(start_byte);
1129            match &self.states[state_id] {
1130                ThompsonStateV1::Split { out1, out2 } => {
1131                    stack.push(*out2);
1132                    stack.push(*out1);
1133                }
1134                ThompsonStateV1::Jump { target } => {
1135                    stack.push(*target);
1136                }
1137                ThompsonStateV1::Match { .. } | ThompsonStateV1::Accept => {}
1138            }
1139        }
1140    }
1141}
1142
1143/// Returns ordered distinct three-byte windows without enumerating substrings.
1144#[must_use]
1145pub fn long_pattern_grams_v1(matched: &str) -> Vec<[u8; 3]> {
1146    distinct_grams(matched.as_bytes())
1147}
1148
1149fn distinct_grams(bytes: &[u8]) -> Vec<[u8; 3]> {
1150    let mut grams = bytes
1151        .windows(3)
1152        .map(|window| [window[0], window[1], window[2]])
1153        .collect::<Vec<_>>();
1154    grams.sort_unstable();
1155    grams.dedup();
1156    grams
1157}
1158
1159/// Collision-resistant digest used only as a candidate filter; callers still verify bytes.
1160#[must_use]
1161pub fn long_pattern_digest_v1(matched: &str) -> [u8; 32] {
1162    let mut digest = Sha256::new();
1163    digest.update(b"RIFFDB-LONG-PATTERN-VALUE-V1\0");
1164    digest.update((matched.len() as u64).to_be_bytes());
1165    digest.update(matched.as_bytes());
1166    digest.finalize().into()
1167}
1168
1169/// Closed pattern compilation refusal.
1170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1171pub enum LongPatternErrorV1 {
1172    /// Source query pattern is empty or exceeds its byte maximum.
1173    PatternBytes,
1174    /// Profile expansion exceeded the matched-value maximum.
1175    MatchedBytes,
1176    /// Transformed wildcard atom count exceeded its maximum.
1177    PatternAtoms,
1178    /// Backslash did not escape exactly `%`, `_`, or backslash.
1179    InvalidEscape,
1180    /// Regular-expression syntax is invalid or unsupported (`ADR-0339`).
1181    InvalidRegex,
1182}
1183
1184// Exhaustive non-durable ownership inventories for exact-provider admission.
1185crate::inspect_owned_capacity_copy_v1!(LongPatternOperatorV1);
1186
1187crate::inspect_owned_capacity_copy_v1!(LongPatternProfileV1);
1188
1189crate::inspect_owned_capacity_copy_v1!(LongPatternBoundsV1);
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    #[test]
1196    fn wildcard_semantics_cover_scalars_escaping_folding_and_negation() {
1197        let like = CompiledLongPatternV1::compile(
1198            LongPatternOperatorV1::Like,
1199            LongPatternProfileV1::BinaryUtf8V1,
1200            r"ab%_\%",
1201        )
1202        .expect("pattern");
1203        assert!(like.matches_source("abλ-tail%"));
1204        assert!(!like.matches_source("ab%"));
1205
1206        let ilike = CompiledLongPatternV1::compile(
1207            LongPatternOperatorV1::ILike,
1208            LongPatternProfileV1::BinaryUtf8V1,
1209            "strasse%",
1210        )
1211        .expect("folded pattern");
1212        assert!(ilike.matches_source("STRAßE-tail"));
1213
1214        let not_like = CompiledLongPatternV1::compile(
1215            LongPatternOperatorV1::NotLike,
1216            LongPatternProfileV1::BinaryUtf8V1,
1217            "%secret%",
1218        )
1219        .expect("negated pattern");
1220        assert!(not_like.matches_source("public"));
1221        assert!(!not_like.matches_source("a secret value"));
1222    }
1223
1224    #[test]
1225    fn invalid_escapes_and_pattern_bytes_fail_closed() {
1226        for pattern in [r"trailing\", r"bad\x"] {
1227            assert_eq!(
1228                CompiledLongPatternV1::compile(
1229                    LongPatternOperatorV1::Like,
1230                    LongPatternProfileV1::BinaryUtf8V1,
1231                    pattern,
1232                ),
1233                Err(LongPatternErrorV1::InvalidEscape)
1234            );
1235        }
1236        assert_eq!(
1237            CompiledLongPatternV1::compile(
1238                LongPatternOperatorV1::Contains,
1239                LongPatternProfileV1::BinaryUtf8V1,
1240                "",
1241            ),
1242            Err(LongPatternErrorV1::PatternBytes)
1243        );
1244    }
1245
1246    #[test]
1247    fn grams_are_distinct_ordered_and_only_acceleration_hints() {
1248        assert_eq!(long_pattern_grams_v1("ababa"), vec![*b"aba", *b"bab"]);
1249        let wildcard = CompiledLongPatternV1::compile(
1250            LongPatternOperatorV1::Like,
1251            LongPatternProfileV1::BinaryUtf8V1,
1252            "%ab%",
1253        )
1254        .expect("short literal");
1255        assert!(wildcard.mandatory_grams().is_empty());
1256        assert!(wildcard.matches_source("zzabzz"));
1257    }
1258
1259    #[test]
1260    fn unanchored_regex_executes_in_single_linear_pass_on_repeated_prefixes() {
1261        let pattern = CompiledLongPatternV1::compile(
1262            LongPatternOperatorV1::MatchesRegex,
1263            LongPatternProfileV1::BinaryUtf8V1,
1264            "aaa.*b",
1265        )
1266        .expect("compile unanchored regex");
1267        assert_eq!(pattern.mandatory_grams(), &[*b"aaa"]);
1268
1269        let repeated_miss = "a".repeat(4_000);
1270        let started = std::time::Instant::now();
1271        assert!(!pattern.matches_source(&repeated_miss));
1272        let elapsed = started.elapsed();
1273        assert!(
1274            elapsed < std::time::Duration::from_millis(50),
1275            "unanchored Thompson NFA should scan 4,000 bytes in linear time, took {elapsed:?}",
1276        );
1277
1278        let mut repeated_hit = "x".repeat(500);
1279        repeated_hit.push_str(&"a".repeat(3_000));
1280        repeated_hit.push('b');
1281        repeated_hit.push_str("tail");
1282        assert_eq!(
1283            pattern.find_first_match_span(&repeated_hit),
1284            Some((500, 3_501))
1285        );
1286    }
1287}