1use std::{error::Error, fmt};
20
21#[derive(Debug, Clone)]
23pub struct CompiledDfa {
24 pub transitions: Vec<u32>,
27 pub accept: Vec<u32>,
30 pub state_count: u32,
32 pub max_pattern_len: u32,
36 pub output_offsets: Vec<u32>,
40 pub output_records: Vec<u32>,
45}
46
47#[derive(Debug, Clone)]
49#[non_exhaustive]
50pub enum DfaCompileError {
51 TooLarge {
53 requested_bytes: usize,
55 budget_bytes: usize,
57 state_count: u32,
59 },
60 TrieStateCapExceeded {
62 state_cap: usize,
64 },
65}
66
67impl fmt::Display for DfaCompileError {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::TooLarge {
71 requested_bytes,
72 budget_bytes,
73 ..
74 } => write!(
75 formatter,
76 "DFA transition table is too large: {requested_bytes} bytes (cap = {budget_bytes}). Fix: reduce the pattern set, raise the budget, or shard patterns into multiple DFAs."
77 ),
78 Self::TrieStateCapExceeded { state_cap } => write!(
79 formatter,
80 "DFA trie exceeded state cap during construction: requested > {state_cap} states. Fix: reduce the pattern set or raise the budget (cap derived from budget_bytes / 1024)."
81 ),
82 }
83 }
84}
85
86impl Error for DfaCompileError {}
87
88const DFA_WIRE_MAGIC: [u8; 4] = *b"VDFA";
97const DFA_WIRE_VERSION: u32 = 2;
98
99#[derive(Debug, Clone)]
104#[non_exhaustive]
105pub enum DfaWireError {
106 Truncated {
108 needed: usize,
110 got: usize,
112 },
113 BadMagic,
116 VersionMismatch {
119 expected: u32,
121 found: u32,
123 },
124 ShapeMismatch {
127 reason: &'static str,
129 },
130 SectionTooLarge {
132 len: usize,
134 max: usize,
136 },
137 Envelope(String),
140}
141
142impl fmt::Display for DfaWireError {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 Self::Truncated { needed, got } => write!(
146 f,
147 "DFA wire blob truncated: needed {needed} bytes, got {got}. \
148 Fix: regenerate the cache."
149 ),
150 Self::BadMagic => write!(
151 f,
152 "DFA wire blob does not start with `VDFA` magic. Fix: this \
153 is not a CompiledDfa::to_bytes payload."
154 ),
155 Self::VersionMismatch { expected, found } => write!(
156 f,
157 "DFA wire blob version {found} does not match the runtime \
158 version {expected}. Fix: discard the cache and recompile \
159 the DFA."
160 ),
161 Self::ShapeMismatch { reason } => write!(
162 f,
163 "DFA wire blob shape mismatch: {reason}. Fix: this blob is \
164 corrupt - discard and recompile."
165 ),
166 Self::SectionTooLarge { len, max } => write!(
167 f,
168 "DFA wire section length {len} exceeds maximum {max}. \
169 Fix: shard the DFA into smaller pattern groups."
170 ),
171 Self::Envelope(message) => write!(f, "DFA wire envelope error: {message}"),
172 }
173 }
174}
175
176impl Error for DfaWireError {}
177
178impl CompiledDfa {
179 #[must_use]
181 pub fn empty() -> Self {
182 Self {
183 transitions: vec![0; 256],
184 accept: vec![0],
185 state_count: 1,
186 max_pattern_len: 0,
187 output_offsets: vec![0, 0],
188 output_records: Vec::new(),
189 }
190 }
191
192 pub fn to_bytes(&self) -> Result<Vec<u8>, DfaWireError> {
212 let mut out = vyre_foundation::serial::WireWriter::new(&DFA_WIRE_MAGIC, DFA_WIRE_VERSION);
213 out.write_u32(self.state_count);
214 out.write_u32(self.max_pattern_len);
215 out.write_words(&self.transitions)
216 .map_err(map_envelope_error)?;
217 out.write_words(&self.accept).map_err(map_envelope_error)?;
218 out.write_words(&self.output_offsets)
219 .map_err(map_envelope_error)?;
220 out.write_words(&self.output_records)
221 .map_err(map_envelope_error)?;
222 Ok(out.into_bytes())
223 }
224
225 pub fn from_bytes(bytes: &[u8]) -> Result<Self, DfaWireError> {
232 let mut reader =
233 vyre_foundation::serial::WireReader::new(bytes, &DFA_WIRE_MAGIC, DFA_WIRE_VERSION)
234 .map_err(map_envelope_error)?;
235 let state_count = reader.read_u32().map_err(map_envelope_error)?;
236 let max_pattern_len = reader.read_u32().map_err(map_envelope_error)?;
237 let transitions = reader.read_words().map_err(map_envelope_error)?;
238 let accept = reader.read_words().map_err(map_envelope_error)?;
239 let output_offsets = reader.read_words().map_err(map_envelope_error)?;
240 let output_records = reader.read_words().map_err(map_envelope_error)?;
241
242 if transitions.len() != (state_count as usize) * 256 {
247 return Err(DfaWireError::ShapeMismatch {
248 reason: "transitions length != state_count * 256",
249 });
250 }
251 if transitions
257 .iter()
258 .any(|&target| target as usize >= state_count as usize)
259 {
260 return Err(DfaWireError::ShapeMismatch {
261 reason: "transition target out of range for state_count",
262 });
263 }
264 if accept.len() != state_count as usize {
265 return Err(DfaWireError::ShapeMismatch {
266 reason: "accept length != state_count",
267 });
268 }
269 if output_offsets.len() != (state_count as usize) + 1 {
270 return Err(DfaWireError::ShapeMismatch {
271 reason: "output_offsets length != state_count + 1",
272 });
273 }
274 if output_offsets.first().copied() != Some(0) {
275 return Err(DfaWireError::ShapeMismatch {
276 reason: "output_offsets must start at zero",
277 });
278 }
279 if output_offsets.last().copied() != Some(output_records.len() as u32) {
280 return Err(DfaWireError::ShapeMismatch {
281 reason: "output_offsets last entry must equal output_records length",
282 });
283 }
284 if output_offsets
285 .windows(2)
286 .any(|window| window[0] > window[1])
287 {
288 return Err(DfaWireError::ShapeMismatch {
289 reason: "output_offsets must be monotonic",
290 });
291 }
292 if output_offsets
293 .iter()
294 .any(|&offset| offset as usize > output_records.len())
295 {
296 return Err(DfaWireError::ShapeMismatch {
297 reason: "output_offsets entries must be within output_records",
298 });
299 }
300 if max_pattern_len == 0 && accept.iter().skip(1).any(|&state| state != 0) {
314 return Err(DfaWireError::ShapeMismatch {
315 reason: "max_pattern_len == 0 but a non-root state accepts",
316 });
317 }
318
319 Ok(Self {
320 transitions,
321 accept,
322 state_count,
323 max_pattern_len,
324 output_offsets,
325 output_records,
326 })
327 }
328}
329
330fn map_envelope_error(error: vyre_foundation::serial::EnvelopeError) -> DfaWireError {
331 match error {
332 vyre_foundation::serial::EnvelopeError::Truncated { needed, got } => {
333 DfaWireError::Truncated { needed, got }
334 }
335 vyre_foundation::serial::EnvelopeError::BadMagic { .. } => DfaWireError::BadMagic,
336 vyre_foundation::serial::EnvelopeError::VersionMismatch { expected, found } => {
337 DfaWireError::VersionMismatch { expected, found }
338 }
339 vyre_foundation::serial::EnvelopeError::SectionTooLarge { len, max } => {
340 DfaWireError::SectionTooLarge { len, max }
341 }
342 error => DfaWireError::Envelope(error.to_string()),
343 }
344}
345
346pub const DEFAULT_DFA_BUDGET_BYTES: usize = 16 * 1024 * 1024;
352
353#[must_use]
367pub fn dfa_compile(patterns: &[&[u8]]) -> CompiledDfa {
368 match dfa_compile_with_budget(patterns, DEFAULT_DFA_BUDGET_BYTES) {
369 Ok(dfa) => dfa,
370 Err(error) => panic!(
371 "dfa_compile: compiling {} pattern(s) exceeded the default {DEFAULT_DFA_BUDGET_BYTES}-byte DFA budget ({error}). \
372 Returning the empty rejecting automaton would silently drop every match; \
373 use dfa_compile_with_budget and shard oversized pattern sets to handle this as a structured error.",
374 patterns.len()
375 ),
376 }
377}
378
379pub fn dfa_compile_with_budget(
389 patterns: &[&[u8]],
390 budget_bytes: usize,
391) -> Result<CompiledDfa, DfaCompileError> {
392 dfa_compile_with_budget_ci(patterns, budget_bytes, false)
393}
394
395#[must_use]
412pub fn dfa_compile_case_insensitive(patterns: &[&[u8]]) -> CompiledDfa {
413 match dfa_compile_case_insensitive_with_budget(patterns, DEFAULT_DFA_BUDGET_BYTES) {
414 Ok(dfa) => dfa,
415 Err(error) => panic!(
416 "dfa_compile_case_insensitive: compiling {} pattern(s) exceeded the default {DEFAULT_DFA_BUDGET_BYTES}-byte DFA budget ({error}). \
417 Returning the empty rejecting automaton would silently drop every match; \
418 use dfa_compile_case_insensitive_with_budget and shard oversized pattern sets to handle this as a structured error.",
419 patterns.len()
420 ),
421 }
422}
423
424pub fn dfa_compile_case_insensitive_with_budget(
429 patterns: &[&[u8]],
430 budget_bytes: usize,
431) -> Result<CompiledDfa, DfaCompileError> {
432 dfa_compile_with_budget_ci(patterns, budget_bytes, true)
433}
434
435fn dfa_compile_with_budget_ci(
436 patterns: &[&[u8]],
437 budget_bytes: usize,
438 case_insensitive: bool,
439) -> Result<CompiledDfa, DfaCompileError> {
440 let state_cap = budget_bytes / (256 * core::mem::size_of::<u32>());
441 let inner = dfa_compile_inner_capped(patterns, state_cap, case_insensitive)?;
442 let requested_bytes = (inner.state_count as usize)
443 .saturating_mul(256)
444 .saturating_mul(core::mem::size_of::<u32>());
445 if requested_bytes > budget_bytes {
446 return Err(DfaCompileError::TooLarge {
447 requested_bytes,
448 budget_bytes,
449 state_count: inner.state_count,
450 });
451 }
452 Ok(inner)
453}
454
455#[inline]
460fn fold_ascii_byte(b: usize, case_insensitive: bool) -> usize {
461 if case_insensitive && (0x41..=0x5A).contains(&b) {
462 b | 0x20
463 } else {
464 b
465 }
466}
467
468fn dfa_compile_inner_capped(
474 patterns: &[&[u8]],
475 state_cap: usize,
476 case_insensitive: bool,
477) -> Result<CompiledDfa, DfaCompileError> {
478 const NO_TRANSITION: u32 = u32::MAX;
479
480 let upper_bound = patterns
481 .iter()
482 .fold(0usize, |acc, p| acc.saturating_add(p.len()))
483 .saturating_add(1);
484 let max_pattern_len = patterns
485 .iter()
486 .map(|pattern| pattern.len())
487 .max()
488 .unwrap_or(0)
489 .min(u32::MAX as usize) as u32;
490 let trie_capacity = state_cap.min(upper_bound).max(1);
491
492 let mut trie: Vec<[u32; 256]> = Vec::with_capacity(trie_capacity);
493 let mut accept: Vec<u32> = Vec::with_capacity(trie_capacity);
494 let mut local_accepts: Vec<Vec<u32>> = Vec::with_capacity(trie_capacity);
495
496 trie.push([NO_TRANSITION; 256]);
497 accept.push(0);
498 local_accepts.push(Vec::new());
499
500 for (pattern_idx, pat) in patterns.iter().enumerate() {
501 let mut cur = 0usize;
502 for &b in *pat {
503 let b = fold_ascii_byte(b as usize, case_insensitive);
507 let next = trie[cur][b];
508 if next != NO_TRANSITION {
509 cur = next as usize;
510 } else {
511 if trie.len() >= state_cap {
512 return Err(DfaCompileError::TrieStateCapExceeded { state_cap });
513 }
514 let new_id = trie.len() as u32;
515 trie.push([NO_TRANSITION; 256]);
516 accept.push(0);
517 local_accepts.push(Vec::new());
518 trie[cur][b] = new_id;
519 cur = new_id as usize;
520 }
521 }
522 local_accepts[cur].push(pattern_idx as u32);
523 if accept[cur] == 0 {
530 accept[cur] = (pattern_idx as u32)
531 .checked_add(1)
532 .expect("pattern_idx must be <= u32::MAX - 1 to fit the pid+1 encoding");
533 }
534 }
535
536 let state_count = trie.len();
537 let mut fail = vec![0u32; state_count];
538 let mut queue = Vec::new();
539 for b in 0..256usize {
540 let child = trie[0][b];
541 if child != NO_TRANSITION {
542 fail[child as usize] = 0;
543 queue.push(child as usize);
544 }
545 }
546 let mut head = 0usize;
547 while head < queue.len() {
548 let state = queue[head];
549 head += 1;
550 for b in 0..256usize {
551 let child = trie[state][b];
552 if child != NO_TRANSITION {
553 let mut f = fail[state] as usize;
554 while f != 0 && trie[f][b] == NO_TRANSITION {
555 f = fail[f] as usize;
556 }
557 let f_child = trie[f][b];
558 if f_child != NO_TRANSITION && f_child != child {
559 fail[child as usize] = f_child;
560 }
561 if accept[child as usize] == 0 {
562 let f_accept = accept[fail[child as usize] as usize];
563 if f_accept != 0 {
564 accept[child as usize] = f_accept;
565 }
566 }
567 queue.push(child as usize);
568 }
569 }
570 }
571
572 let mut bfs_order = Vec::with_capacity(state_count);
573 let mut bfs_queue = Vec::with_capacity(state_count);
574 bfs_queue.push(0usize);
575 let mut bfs_head = 0usize;
576 while bfs_head < bfs_queue.len() {
577 let state = bfs_queue[bfs_head];
578 bfs_head += 1;
579 bfs_order.push(state);
580
581 for b in 0..256usize {
582 let child = trie[state][b];
583 if child != NO_TRANSITION {
584 bfs_queue.push(child as usize);
585 }
586 }
587 }
588
589 let mut output_counts = vec![0usize; state_count];
590 for &state in &bfs_order {
591 let f = fail[state] as usize;
592 let inherited = if f != 0 && f != state {
593 output_counts[f]
594 } else {
595 0
596 };
597 let adds_local = local_accepts[state]
598 .iter()
599 .filter(|&&pattern| !fail_chain_accepts_pattern(state, pattern, &fail, &local_accepts))
600 .count();
601 output_counts[state] = inherited + adds_local;
602 }
603
604 let mut output_offsets = vec![0u32; state_count + 1];
605 for state in 0..state_count {
606 output_offsets[state + 1] =
607 output_offsets[state].saturating_add(output_counts[state] as u32);
608 }
609 let mut output_records = vec![0u32; output_offsets[state_count] as usize];
610 for &state in &bfs_order {
611 let mut write = output_offsets[state] as usize;
612 let f = fail[state] as usize;
613 if f != 0 && f != state {
614 let start = output_offsets[f] as usize;
615 let end = output_offsets[f + 1] as usize;
616 let len = end - start;
617 output_records.copy_within(start..end, write);
618 write += len;
619 }
620 for &pattern in &local_accepts[state] {
621 let start = output_offsets[state] as usize;
622 if !output_records[start..write].contains(&pattern) {
623 output_records[write] = pattern;
624 write += 1;
625 }
626 }
627 debug_assert_eq!(write, output_offsets[state + 1] as usize);
628 }
629
630 let mut transitions = vec![0u32; state_count * 256];
631 let mut accept_out = vec![0u32; state_count];
632 for state in 0..state_count {
633 accept_out[state] = accept[state];
634 for b in 0..256usize {
635 let fb = fold_ascii_byte(b, case_insensitive);
641 let mut s = state;
642 loop {
643 let child = trie[s][fb];
644 if child != NO_TRANSITION {
645 transitions[state * 256 + b] = child;
646 break;
647 }
648 if s == 0 {
649 transitions[state * 256 + b] = 0;
650 break;
651 }
652 s = fail[s] as usize;
653 }
654 }
655 }
656
657 Ok(CompiledDfa {
658 transitions,
659 accept: accept_out,
660 state_count: state_count as u32,
661 max_pattern_len,
662 output_offsets,
663 output_records,
664 })
665}
666
667fn fail_chain_accepts_pattern(
668 state: usize,
669 pattern: u32,
670 fail: &[u32],
671 local_accepts: &[Vec<u32>],
672) -> bool {
673 let mut f = fail[state] as usize;
674 while f != 0 && f != state {
675 if local_accepts[f].contains(&pattern) {
676 return true;
677 }
678 let next = fail[f] as usize;
679 if next == f {
680 return false;
681 }
682 f = next;
683 }
684 false
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn single_string_matches_only_its_suffix() {
693 let dfa = dfa_compile(&[b"abc"]);
694 let input = b"xxabcxx";
695
696 let mut s = 0usize;
699 for &b in b"xxabc" {
700 s = dfa.transitions[s * 256 + b as usize] as usize;
701 }
702 assert_eq!(
706 dfa.accept[s], 1,
707 "after 'xxabc' the DFA must be in a state that accepts pattern 0 (encoded as 1); \
708 got accept[{s}] = {}",
709 dfa.accept[s]
710 );
711 let rec_start = dfa.output_offsets[s] as usize;
713 let rec_end = dfa.output_offsets[s + 1] as usize;
714 assert_eq!(
715 &dfa.output_records[rec_start..rec_end],
716 &[0u32],
717 "output_records for the accept state must contain exactly [0] (pid=0)"
718 );
719
720 let s_after_x = dfa.transitions[s * 256 + b'x' as usize] as usize;
722 assert_eq!(
723 dfa.accept[s_after_x], 0,
724 "after trailing 'x' the DFA must not accept; pattern 'abc' ends before it"
725 );
726 }
727
728 fn scan_ends(dfa: &CompiledDfa, haystack: &[u8]) -> std::collections::BTreeSet<(u32, u32)> {
731 let mut state = 0usize;
732 let mut out = std::collections::BTreeSet::new();
733 for (pos, &b) in haystack.iter().enumerate() {
734 state = dfa.transitions[state * 256 + b as usize] as usize;
735 let begin = dfa.output_offsets[state] as usize;
736 let end = dfa.output_offsets[state + 1] as usize;
737 for &pid in &dfa.output_records[begin..end] {
738 out.insert((pid, pos as u32));
739 }
740 }
741 out
742 }
743
744 #[test]
745 fn case_insensitive_matches_every_case_variant() {
746 let dfa = dfa_compile_case_insensitive(&[b"key"]);
747 for variant in [b"KEY", b"Key", b"kEy", b"keY", b"kEY", b"key"] {
749 let hits = scan_ends(&dfa, variant);
750 assert!(
751 hits.contains(&(0, 2)),
752 "case-insensitive DFA must match {:?} as pattern 0 ending at 2, got {hits:?}",
753 std::str::from_utf8(variant).unwrap()
754 );
755 }
756 assert!(
758 scan_ends(&dfa, b"kez").is_empty(),
759 "case-insensitive folding must not match a non-variant string"
760 );
761 }
762
763 #[test]
764 fn case_insensitive_is_identical_to_host_folded_case_sensitive() {
765 let alphabet = b"aAbBkK_9/";
769 let mut seed = 0x9E37_79B1u64;
770 let mut next = || {
771 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
772 (seed >> 33) as u32
773 };
774 for _ in 0..500 {
775 let pat_count = 1 + (next() % 4) as usize;
777 let patterns_owned: Vec<Vec<u8>> = (0..pat_count)
778 .map(|_| {
779 let len = 1 + (next() % 5) as usize;
780 (0..len)
781 .map(|_| alphabet[(next() as usize) % alphabet.len()])
782 .collect()
783 })
784 .collect();
785 let patterns: Vec<&[u8]> = patterns_owned.iter().map(Vec::as_slice).collect();
786
787 let hay_len = 4 + (next() % 40) as usize;
788 let haystack: Vec<u8> = (0..hay_len)
789 .map(|_| alphabet[(next() as usize) % alphabet.len()])
790 .collect();
791
792 let ci = dfa_compile_case_insensitive(&patterns);
794 let ci_hits = scan_ends(&ci, &haystack);
795
796 let lowered_pat: Vec<Vec<u8>> = patterns_owned
799 .iter()
800 .map(|p| p.iter().map(|b| b.to_ascii_lowercase()).collect())
801 .collect();
802 let lowered_refs: Vec<&[u8]> = lowered_pat.iter().map(Vec::as_slice).collect();
803 let lowered_hay: Vec<u8> = haystack.iter().map(|b| b.to_ascii_lowercase()).collect();
804 let reference = dfa_compile(&lowered_refs);
805 let ref_hits = scan_ends(&reference, &lowered_hay);
806
807 assert_eq!(
808 ci_hits, ref_hits,
809 "case-insensitive DFA over raw haystack must equal host-folded case-sensitive scan\n\
810 patterns={patterns_owned:?}\n\
811 haystack={:?}",
812 String::from_utf8_lossy(&haystack)
813 );
814 }
815 }
816
817 #[test]
818 fn overlapping_patterns_both_accept() {
819 let patterns: [&[u8]; 4] = [b"he", b"she", b"his", b"hers"];
820 let dfa = dfa_compile(&patterns);
821 let mut state = 0u32;
822 let mut matches = Vec::new();
823 for &b in b"ushers" {
824 state = dfa.transitions[(state as usize) * 256 + (b as usize)];
825 let accept = dfa.accept[state as usize];
826 if accept != 0 {
827 matches.push(accept - 1);
828 }
829 }
830 assert!(matches.contains(&1), "must accept `she`");
831 assert!(
832 matches.contains(&0) || matches.contains(&3),
833 "must accept `he` or `hers`"
834 );
835 }
836
837 #[test]
838 fn duplicate_literals_preserve_distinct_output_records() {
839 let dfa = dfa_compile(&[b"B".as_slice(), b"B".as_slice(), b"AB".as_slice()]);
840 let state_b = dfa.transitions[b'B' as usize] as usize;
841 let state_ab = {
842 let state_a = dfa.transitions[b'A' as usize] as usize;
843 dfa.transitions[state_a * 256 + b'B' as usize] as usize
844 };
845
846 let b_start = dfa.output_offsets[state_b] as usize;
847 let b_end = dfa.output_offsets[state_b + 1] as usize;
848 assert_eq!(
849 &dfa.output_records[b_start..b_end],
850 &[0, 1],
851 "Fix: exact duplicate literals must keep both consumer pattern ids in output_records."
852 );
853
854 let ab_start = dfa.output_offsets[state_ab] as usize;
855 let ab_end = dfa.output_offsets[state_ab + 1] as usize;
856 assert_eq!(
857 &dfa.output_records[ab_start..ab_end],
858 &[0, 1, 2],
859 "Fix: suffix inheritance must preserve duplicate suffix pattern ids plus the local longer pattern."
860 );
861 }
862
863 #[test]
864 fn empty_pattern_list_yields_trivial_dfa() {
865 let dfa = dfa_compile(&[]);
866 assert_eq!(dfa.state_count, 1);
867 assert_eq!(dfa.transitions.len(), 256);
868 assert!(dfa.transitions.iter().all(|&t| t == 0));
869 assert_eq!(dfa.accept, vec![0]);
870 }
871
872 #[test]
873 fn budget_exhaustion_returns_structured_error() {
874 let err = dfa_compile_with_budget(&[b"ab", b"cd"], 1024).unwrap_err();
875 match err {
876 DfaCompileError::TooLarge {
877 requested_bytes,
878 budget_bytes,
879 state_count,
880 } => {
881 assert!(
882 requested_bytes > budget_bytes,
883 "TooLarge must carry requested > budget"
884 );
885 assert_eq!(budget_bytes, 1024);
886 assert!(state_count >= 1);
887 }
888 DfaCompileError::TrieStateCapExceeded { state_cap } => {
889 assert!(state_cap <= 1024);
890 }
891 }
892 }
893
894 #[test]
895 fn generous_budget_succeeds() {
896 let dfa = dfa_compile_with_budget(&[b"abc"], DEFAULT_DFA_BUDGET_BYTES)
897 .expect("Fix: generous budget must succeed; restore this invariant before continuing.");
898 assert!(dfa.state_count >= 1);
899 }
900
901 #[test]
902 fn zero_budget_rejects_every_nonempty_dfa() {
903 let err = dfa_compile_with_budget(&[b"a"], 0).unwrap_err();
904 assert!(matches!(
905 err,
906 DfaCompileError::TooLarge { .. } | DfaCompileError::TrieStateCapExceeded { .. }
907 ));
908 }
909
910 #[test]
918 fn empty_pattern_dfa_round_trips() {
919 let dfa = dfa_compile(&[b"".as_slice()]);
920 assert_eq!(
922 dfa.accept[0], 1,
923 "dfa_compile(&[b\"\"]) root state must accept pattern 0 (accept=1)"
924 );
925 assert_eq!(
926 dfa.max_pattern_len, 0,
927 "empty pattern must produce max_pattern_len=0"
928 );
929 let bytes = dfa
930 .to_bytes()
931 .expect("Fix: serialization must succeed for empty-pattern DFA");
932 let dfa2 = CompiledDfa::from_bytes(&bytes)
933 .expect("Fix: round-trip must succeed for empty-pattern DFA");
934 assert_eq!(
935 dfa2.accept[0], 1,
936 "deserialized DFA must preserve accept[0]=1 for empty-pattern compile"
937 );
938 assert_eq!(
939 dfa2.max_pattern_len, 0,
940 "deserialized DFA must preserve max_pattern_len=0"
941 );
942 }
943
944 #[test]
945 fn from_bytes_rejects_zero_max_pattern_len_with_non_root_accept() {
946 let mut dfa = dfa_compile(&[b"AKIA".as_slice()]);
953 assert!(
954 dfa.max_pattern_len >= 1,
955 "precondition: AKIA must compile to max_pattern_len >= 1, got {}",
956 dfa.max_pattern_len
957 );
958 assert!(
959 dfa.accept.iter().skip(1).any(|&state| state != 0),
960 "precondition: AKIA must have a non-root accept state"
961 );
962 dfa.max_pattern_len = 0;
965 let bytes = dfa.to_bytes().expect("encode forged DFA wire blob");
966 let err = CompiledDfa::from_bytes(&bytes).unwrap_err();
967 assert!(
968 matches!(
969 err,
970 DfaWireError::ShapeMismatch {
971 reason: "max_pattern_len == 0 but a non-root state accepts"
972 }
973 ),
974 "expected ShapeMismatch with the non-root-accept reason, got {err:?}"
975 );
976 }
977
978 #[test]
979 fn from_bytes_rejects_out_of_range_transition_target() {
980 let mut dfa = dfa_compile(&[b"abc".as_slice()]);
985 assert!(
986 dfa.state_count >= 2,
987 "precondition: fixture must have real states"
988 );
989 assert!(
990 dfa.transitions
991 .iter()
992 .all(|&t| (t as usize) < dfa.state_count as usize),
993 "precondition: an honest compile keeps every transition target in range"
994 );
995 dfa.transitions[0] = dfa.state_count;
999 let bytes = dfa.to_bytes().expect("encode forged DFA wire blob");
1000 let err = CompiledDfa::from_bytes(&bytes).unwrap_err();
1001 assert!(
1002 matches!(
1003 err,
1004 DfaWireError::ShapeMismatch {
1005 reason: "transition target out of range for state_count"
1006 }
1007 ),
1008 "expected the transition-target range violation, got {err:?}"
1009 );
1010 }
1011
1012 #[test]
1013 fn duplicate_literal_accept_field_contains_first_pattern() {
1014 let dfa = dfa_compile(&[b"B".as_slice(), b"B".as_slice()]);
1018 let state_b = dfa.transitions[b'B' as usize] as usize;
1019 assert_eq!(
1020 dfa.accept[state_b],
1021 1,
1022 "first duplicate literal (pid=0) must win the accept fast-path field (encoded as pid+1=1); \
1023 last-writer-wins would give 2 (pid=1)"
1024 );
1025 let start = dfa.output_offsets[state_b] as usize;
1027 let end = dfa.output_offsets[state_b + 1] as usize;
1028 assert_eq!(
1029 &dfa.output_records[start..end],
1030 &[0u32, 1u32],
1031 "duplicate literals must both appear in output_records"
1032 );
1033 }
1034
1035 #[test]
1036 fn infallible_compile_does_not_silently_return_empty_on_error() {
1037 let src = std::fs::read_to_string(concat!(
1038 env!("CARGO_MANIFEST_DIR"),
1039 "/src/matching/dfa_compile.rs"
1040 ))
1041 .expect("Fix: DFA compiler source must be readable");
1042 let production = src
1043 .split("#[cfg(test)]")
1044 .next()
1045 .expect("Fix: meta-test scans production sources; update fixture path if module moved - production section must exist");
1046 assert!(
1047 !production.contains("unwrap_or_else(|_| CompiledDfa::empty())"),
1048 "dfa_compile must never hide a failed compile by returning the empty rejecting automaton"
1049 );
1050 assert!(
1051 production.contains("use dfa_compile_with_budget and shard oversized pattern sets"),
1052 "dfa_compile panic must explain the structured recovery path"
1053 );
1054 }
1055}