1use sha2::{Digest, Sha256};
4
5pub const MAX_LONG_PATTERN_SOURCE_BYTES_V1: usize = 8_000;
7pub const MAX_LONG_PATTERN_MATCHED_BYTES_V1: usize = 144_000;
9pub const MAX_LONG_PATTERN_QUERY_BYTES_V1: usize = 8_000;
11pub const MAX_LONG_PATTERN_ATOMS_V1: usize = 144_000;
13pub const MAX_LONG_PATTERN_ROWS_PER_PARTITION_V1: u32 = 65_535;
15
16#[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 #[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 #[must_use]
98 pub const fn source_bytes(self) -> u32 {
99 self.source_bytes
100 }
101 #[must_use]
103 pub const fn matched_bytes(self) -> u32 {
104 self.matched_bytes
105 }
106 #[must_use]
108 pub const fn rows(self) -> u32 {
109 self.rows
110 }
111 #[must_use]
113 pub const fn total_matched_bytes(self) -> u64 {
114 self.total_matched_bytes
115 }
116 #[must_use]
118 pub const fn distinct_grams(self) -> u32 {
119 self.distinct_grams
120 }
121 #[must_use]
123 pub const fn postings(self) -> u64 {
124 self.postings
125 }
126 #[must_use]
128 pub const fn postings_bytes(self) -> u64 {
129 self.postings_bytes
130 }
131 #[must_use]
133 pub const fn grams_per_row(self) -> u32 {
134 self.grams_per_row
135 }
136 #[must_use]
138 pub const fn pattern_bytes(self) -> u32 {
139 self.pattern_bytes
140 }
141 #[must_use]
143 pub const fn wildcard_atoms(self) -> u32 {
144 self.wildcard_atoms
145 }
146 #[must_use]
148 pub const fn literal_runs(self) -> u32 {
149 self.literal_runs
150 }
151 #[must_use]
153 pub const fn candidates(self) -> u32 {
154 self.candidates
155 }
156 #[must_use]
158 pub const fn verification_bytes(self) -> u64 {
159 self.verification_bytes
160 }
161 #[must_use]
163 pub const fn results(self) -> u32 {
164 self.results
165 }
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub struct LongPatternBoundsErrorV1;
171
172#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174#[repr(u8)]
175pub enum LongPatternProfileV1 {
176 BinaryUtf8V1 = 1,
178 UnicodeFoldV1 = 2,
180}
181
182impl LongPatternProfileV1 {
183 #[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
205#[repr(u8)]
206pub enum LongPatternOperatorV1 {
207 Equals = 1,
209 StartsWith = 2,
211 EndsWith = 3,
213 Contains = 4,
215 Like = 5,
217 ILike = 6,
219 NotLike = 7,
221 NotILike = 8,
223 MatchesRegex = 9,
225}
226
227pub const MAX_REGEX_PATTERN_BYTES_V1: usize = 1_024;
229pub const MAX_REGEX_NFA_STATES_V1: usize = 256;
231
232impl LongPatternOperatorV1 {
233 #[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 #[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 #[must_use]
261 pub const fn is_regex(self) -> bool {
262 matches!(self, Self::MatchesRegex)
263 }
264
265 #[must_use]
267 pub const fn is_case_insensitive(self) -> bool {
268 matches!(self, Self::ILike | Self::NotILike)
269 }
270
271 #[must_use]
273 pub const fn is_negated(self) -> bool {
274 matches!(self, Self::NotLike | Self::NotILike)
275 }
276}
277
278#[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#[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#[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 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 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 #[must_use]
472 pub const fn operator(&self) -> LongPatternOperatorV1 {
473 self.operator
474 }
475
476 #[must_use]
478 pub const fn profile(&self) -> LongPatternProfileV1 {
479 self.profile
480 }
481
482 #[must_use]
484 pub fn mandatory_grams(&self) -> &[[u8; 3]] {
485 &self.mandatory_grams
486 }
487
488 #[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 #[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 #[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 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(¤t, 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(¤t, 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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1171pub enum LongPatternErrorV1 {
1172 PatternBytes,
1174 MatchedBytes,
1176 PatternAtoms,
1178 InvalidEscape,
1180 InvalidRegex,
1182}
1183
1184crate::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}