Skip to main content

guard_core_engine/redos/
parse_slots.rs

1//! Slot decomposition of a parsed pattern.
2//!
3//! Port of the reference `_redos_parse_slots.py` (minus the `re._parser`
4//! import: the tree comes from [`super::ast`]) plus the interval helpers
5//! the reference keeps beside it (`_node_intervals`, category intervals,
6//! `_candidate_chars_for_atom_text`).
7
8use std::sync::LazyLock;
9
10use super::ast::{self, At, Category, ClassItem, Flags, Op, RepeatKind};
11use super::categories::{
12    CATEGORY_DIGIT_INTERVALS, CATEGORY_SPACE_INTERVALS, CATEGORY_WORD_INTERVALS,
13};
14use super::fold::expand_ignorecase;
15use super::intervals::IntervalSet;
16
17/// The reference `_OVERLAP_PROBE_ALPHABET` (`string.printable`): digits,
18/// letters, punctuation, then the whitespace run, in exactly this order.
19pub const PRINTABLE: &[char] = &[
20    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
21    'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B',
22    'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U',
23    'V', 'W', 'X', 'Y', 'Z', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.',
24    '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', ' ',
25    '\t', '\n', '\r', '\x0b', '\x0c',
26];
27
28/// A character-consuming atom (the reference `_PairingAtom`).
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PairingAtom {
31    pub intervals: IntervalSet,
32    pub allows_zero: bool,
33    pub unbounded: bool,
34    pub max_repeat: Option<u32>,
35    pub variable_bounded: bool,
36}
37
38/// A boundary or nested construct (the reference `_NonPairingSlot`).
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct NonPairingSlot {
41    pub is_boundary: bool,
42    pub inner: Option<Vec<Vec<Slot>>>,
43    pub unbounded: bool,
44    pub max_repeat: Option<u32>,
45    pub variable_bounded: bool,
46}
47
48/// The reference `_Slot` union.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Slot {
51    Pairing(PairingAtom),
52    NonPairing(NonPairingSlot),
53}
54
55static ASCII_DIGIT: LazyLock<IntervalSet> = LazyLock::new(|| IntervalSet::from_range(0x30, 0x39));
56static ASCII_WORD: LazyLock<IntervalSet> =
57    LazyLock::new(|| IntervalSet::new(&[(0x30, 0x39), (0x41, 0x5A), (0x5F, 0x5F), (0x61, 0x7A)]));
58static ASCII_SPACE: LazyLock<IntervalSet> =
59    LazyLock::new(|| IntervalSet::new(&[(0x09, 0x0D), (0x20, 0x20)]));
60
61fn category_intervals(category: Category, flags: Flags) -> IntervalSet {
62    let ascii = flags.ascii;
63    match category {
64        Category::Digit => {
65            if ascii {
66                ASCII_DIGIT.clone()
67            } else {
68                IntervalSet::from_normalized(CATEGORY_DIGIT_INTERVALS.to_vec())
69            }
70        }
71        Category::Space => {
72            if ascii {
73                ASCII_SPACE.clone()
74            } else {
75                IntervalSet::from_normalized(CATEGORY_SPACE_INTERVALS.to_vec())
76            }
77        }
78        Category::Word => {
79            if ascii {
80                ASCII_WORD.clone()
81            } else {
82                IntervalSet::from_normalized(CATEGORY_WORD_INTERVALS.to_vec())
83            }
84        }
85        Category::NotDigit => if ascii {
86            ASCII_DIGIT.clone()
87        } else {
88            IntervalSet::from_normalized(CATEGORY_DIGIT_INTERVALS.to_vec())
89        }
90        .complement(),
91        Category::NotSpace => if ascii {
92            ASCII_SPACE.clone()
93        } else {
94            IntervalSet::from_normalized(CATEGORY_SPACE_INTERVALS.to_vec())
95        }
96        .complement(),
97        Category::NotWord => if ascii {
98            ASCII_WORD.clone()
99        } else {
100            IntervalSet::from_normalized(CATEGORY_WORD_INTERVALS.to_vec())
101        }
102        .complement(),
103    }
104}
105
106fn apply_ignorecase(intervals: &IntervalSet, flags: Flags) -> IntervalSet {
107    if !flags.ignorecase {
108        return intervals.clone();
109    }
110    expand_ignorecase(intervals, flags.ascii)
111}
112
113fn member_intervals(item: &ClassItem, flags: Flags) -> IntervalSet {
114    match item {
115        ClassItem::Category(category) => category_intervals(*category, flags),
116        ClassItem::Range(low, high) => {
117            apply_ignorecase(&IntervalSet::from_range(*low, *high), flags)
118        }
119        ClassItem::Literal(cp) => apply_ignorecase(&IntervalSet::single(*cp), flags),
120        ClassItem::Negate => IntervalSet::empty(),
121    }
122}
123
124/// Reference `_in_intervals`.
125#[must_use]
126pub fn in_intervals(items: &[ClassItem], flags: Flags) -> IntervalSet {
127    let mut negate = false;
128    let mut member = IntervalSet::empty();
129    for item in items {
130        if matches!(item, ClassItem::Negate) {
131            negate = true;
132        }
133        // The marker contributes no intervals either way, so every item
134        // dispatches through the same member walk.
135        member = member.union(&member_intervals(item, flags));
136    }
137    if negate { member.complement() } else { member }
138}
139
140fn any_intervals(flags: Flags) -> IntervalSet {
141    if flags.dotall {
142        IntervalSet::full()
143    } else {
144        IntervalSet::full().difference(&IntervalSet::single(u32::from('\n')))
145    }
146}
147
148/// Reference `_node_intervals`.
149#[must_use]
150pub fn node_intervals(op: &Op, flags: Flags) -> IntervalSet {
151    match op {
152        Op::NotLiteral(cp) => apply_ignorecase(&IntervalSet::single(*cp), flags).complement(),
153        Op::In(items) => in_intervals(items, flags),
154        Op::Any => any_intervals(flags),
155        Op::Category(category) => category_intervals(*category, flags),
156        Op::Literal(cp) => apply_ignorecase(&IntervalSet::single(*cp), flags),
157        _ => IntervalSet::empty(),
158    }
159}
160
161fn pairing_atom(
162    op: &Op,
163    flags: Flags,
164    allows_zero: bool,
165    unbounded: bool,
166    max_repeat: Option<u32>,
167    variable_bounded: bool,
168) -> Slot {
169    Slot::Pairing(PairingAtom {
170        intervals: node_intervals(op, flags),
171        allows_zero,
172        unbounded,
173        max_repeat,
174        variable_bounded,
175    })
176}
177
178fn nonpairing_slot(
179    op: &Op,
180    flags: Flags,
181    allows_zero: bool,
182    unbounded: bool,
183    max_repeat: Option<u32>,
184    variable_bounded: bool,
185) -> Slot {
186    match op {
187        Op::Branch(alternatives) => Slot::NonPairing(NonPairingSlot {
188            is_boundary: !allows_zero,
189            inner: Some(
190                alternatives
191                    .iter()
192                    .map(|alt| walk_sequence(alt, flags))
193                    .collect(),
194            ),
195            unbounded,
196            max_repeat,
197            variable_bounded,
198        }),
199        Op::SubPattern { add, del, body, .. } => {
200            let mut child_flags = flags;
201            child_flags.apply_delta(add, del);
202            Slot::NonPairing(NonPairingSlot {
203                is_boundary: !allows_zero,
204                inner: Some(sequence_to_alternatives(body, child_flags)),
205                unbounded,
206                max_repeat,
207                variable_bounded,
208            })
209        }
210        Op::Assert { body, .. } => Slot::NonPairing(NonPairingSlot {
211            is_boundary: false,
212            inner: Some(sequence_to_alternatives(body, flags)),
213            unbounded: false,
214            max_repeat: None,
215            variable_bounded: false,
216        }),
217        Op::At(At::Boundary) | Op::At(At::NonBoundary) | Op::At(_) => {
218            Slot::NonPairing(NonPairingSlot {
219                is_boundary: false,
220                inner: None,
221                unbounded: false,
222                max_repeat: None,
223                variable_bounded: false,
224            })
225        }
226        _ => Slot::NonPairing(NonPairingSlot {
227            is_boundary: true,
228            inner: None,
229            unbounded: false,
230            max_repeat: None,
231            variable_bounded: false,
232        }),
233    }
234}
235
236fn unrepeated_slot(op: &Op, flags: Flags) -> Slot {
237    if op.is_pairing() {
238        return pairing_atom(op, flags, false, false, None, false);
239    }
240    nonpairing_slot(op, flags, false, false, None, false)
241}
242
243fn repeat_slot(kind: RepeatKind, low: u32, high: Option<u32>, body: &[Op], flags: Flags) -> Slot {
244    let _ = kind;
245    let allows_zero = low == 0;
246    let unbounded = high.is_none();
247    let variable_bounded = !unbounded && low < high.unwrap_or(low);
248    let max_repeat = high;
249    if body.len() == 1 {
250        let inner = &body[0];
251        if inner.is_pairing() {
252            return pairing_atom(
253                inner,
254                flags,
255                allows_zero,
256                unbounded,
257                max_repeat,
258                variable_bounded,
259            );
260        }
261        return nonpairing_slot(
262            inner,
263            flags,
264            allows_zero,
265            unbounded,
266            max_repeat,
267            variable_bounded,
268        );
269    }
270    Slot::NonPairing(NonPairingSlot {
271        is_boundary: !allows_zero,
272        inner: Some(sequence_to_alternatives(body, flags)),
273        unbounded,
274        max_repeat,
275        variable_bounded,
276    })
277}
278
279fn slot_for_flat_item(op: &Op, flags: Flags) -> Slot {
280    match op {
281        Op::Repeat {
282            kind,
283            low,
284            high,
285            body,
286        } => repeat_slot(*kind, *low, *high, body, flags),
287        other => unrepeated_slot(other, flags),
288    }
289}
290
291/// Reference `_walk_sequence`.
292#[must_use]
293pub fn walk_sequence(ops: &[Op], flags: Flags) -> Vec<Slot> {
294    ops.iter().map(|op| slot_for_flat_item(op, flags)).collect()
295}
296
297/// Reference `_sequence_to_alternatives`.
298#[must_use]
299pub fn sequence_to_alternatives(ops: &[Op], flags: Flags) -> Vec<Vec<Slot>> {
300    if let [op] = ops
301        && let Op::Branch(alternatives) = op
302    {
303        return alternatives
304            .iter()
305            .map(|alt| walk_sequence(alt, flags))
306            .collect();
307    }
308    vec![walk_sequence(ops, flags)]
309}
310
311/// Reference `_pattern_slots`: `None` when the pattern fails to parse.
312#[must_use]
313pub fn pattern_slots(pattern: &str, flags: Flags) -> Option<Vec<Slot>> {
314    let (ops, final_flags) = ast::parse(pattern, flags).ok()?;
315    Some(walk_sequence(&ops, final_flags))
316}
317
318/// Reference `_candidate_chars_for_atom_text`: the first member of every
319/// component interval of a single-atom text.
320#[must_use]
321pub fn candidate_chars_for_atom_text(atom_text: &str, flags: Flags) -> Vec<char> {
322    let Ok((ops, final_flags)) = ast::parse(atom_text, flags) else {
323        return Vec::new();
324    };
325    if ops.len() != 1 {
326        return Vec::new();
327    }
328    node_intervals(&ops[0], final_flags)
329        .component_first_members()
330        .iter()
331        .filter_map(|cp| char::from_u32(*cp))
332        .collect()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    /// Destructure a pairing slot; panics on any other variant.
340    fn expect_pairing(slot: &Slot) -> &PairingAtom {
341        match slot {
342            Slot::Pairing(atom) => atom,
343            other => panic!("expected pairing, got {other:?}"),
344        }
345    }
346
347    #[test]
348    fn expect_pairing_rejects_non_pairing_slots() {
349        let non = Slot::NonPairing(NonPairingSlot {
350            is_boundary: true,
351            inner: None,
352            unbounded: false,
353            max_repeat: None,
354            variable_bounded: false,
355        });
356        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
357            expect_pairing(&non);
358        }));
359        assert!(result.is_err(), "non-pairing slots must be rejected");
360    }
361
362    /// Destructure a non-pairing slot; panics on any other variant.
363    fn expect_non_pairing(slot: &Slot) -> &NonPairingSlot {
364        match slot {
365            Slot::NonPairing(non) => non,
366            other => panic!("expected non-pairing, got {other:?}"),
367        }
368    }
369
370    #[test]
371    fn candidate_chars_for_a_single_class_are_component_starts() {
372        // Every Nd block start is a component start, exactly like the
373        // reference's `component_first_members`.
374        let digit_starts = candidate_chars_for_atom_text(r"\d", Flags::default());
375        assert_eq!(digit_starts.first().copied(), Some('0'));
376        assert_eq!(digit_starts.len(), 71);
377        assert_eq!(
378            candidate_chars_for_atom_text("[a-c]", Flags::default()),
379            vec!['a']
380        );
381    }
382
383    #[test]
384    fn candidate_chars_empty_for_parse_failures_and_multi_node_texts() {
385        assert!(candidate_chars_for_atom_text("[oops", Flags::default()).is_empty());
386        assert!(candidate_chars_for_atom_text("ab", Flags::default()).is_empty());
387    }
388
389    #[test]
390    fn candidate_chars_reflect_inline_dotall() {
391        // Inline (?s) makes the ANY node cover the newline; without it the
392        // two components start at NUL and VT.
393        let chars = candidate_chars_for_atom_text("(?s).", Flags::default());
394        assert_eq!(chars, vec!['\0']);
395        let chars = candidate_chars_for_atom_text(".", Flags::default());
396        assert_eq!(chars, vec!['\0', '\u{b}']);
397    }
398
399    #[test]
400    fn pattern_slots_none_on_a_parse_failure() {
401        assert!(pattern_slots("[unterminated", Flags::default()).is_none());
402    }
403
404    #[test]
405    fn negated_class_is_a_pairing_atom_with_the_complement() {
406        let slots = pattern_slots("[^a]", Flags::default()).expect("parses");
407        assert_eq!(slots.len(), 1);
408        let atom = expect_pairing(&slots[0]);
409        assert!(!atom.allows_zero);
410        assert!(!atom.unbounded);
411        let expected = IntervalSet::full().difference(&IntervalSet::single(u32::from('a')));
412        assert_eq!(atom.intervals, expected);
413    }
414
415    #[test]
416    fn dotall_widens_any_to_the_full_alphabet() {
417        let dotted = pattern_slots("(?s).", Flags::default()).expect("parses");
418        let plain = pattern_slots(".", Flags::default()).expect("parses");
419        let dotted_atom = expect_pairing(&dotted[0]);
420        let plain_atom = expect_pairing(&plain[0]);
421        assert_eq!(dotted_atom.intervals, IntervalSet::full());
422        assert_eq!(
423            plain_atom.intervals,
424            IntervalSet::full().difference(&IntervalSet::single(u32::from('\n')))
425        );
426    }
427
428    #[test]
429    fn backreference_is_a_hard_boundary_slot() {
430        let slots = pattern_slots(r"(a)\1", Flags::default()).expect("parses");
431        assert_eq!(slots.len(), 2);
432        let non = expect_non_pairing(&slots[1]);
433        assert!(non.is_boundary);
434        assert!(non.inner.is_none());
435    }
436
437    #[test]
438    fn quantified_wrapped_group_keeps_its_inner_slots() {
439        let slots = pattern_slots(r"(\s)+", Flags::default()).expect("parses");
440        let non = expect_non_pairing(&slots[0]);
441        assert!(non.unbounded);
442        // A mandatory (low=1) group repeat is a boundary slot, exactly like
443        // the reference's `not allows_zero`.
444        assert!(non.is_boundary);
445        let inner = non.inner.as_ref().expect("group body");
446        assert_eq!(inner.len(), 1);
447    }
448
449    #[test]
450    fn bounded_repeat_carries_max_repeat_and_variability() {
451        let slots = pattern_slots(r"a{2,5}", Flags::default()).expect("parses");
452        let atom = expect_pairing(&slots[0]);
453        assert_eq!(atom.max_repeat, Some(5));
454        assert!(atom.variable_bounded);
455        assert!(!atom.unbounded);
456    }
457
458    #[test]
459    fn zero_low_repeat_allows_zero() {
460        let slots = pattern_slots(r"a*", Flags::default()).expect("parses");
461        let atom = expect_pairing(&slots[0]);
462        assert!(atom.allows_zero);
463        assert!(atom.unbounded);
464    }
465
466    #[test]
467    fn assertions_are_non_boundary_slots() {
468        let slots = pattern_slots(r"(?=a)", Flags::default()).expect("parses");
469        let non = expect_non_pairing(&slots[0]);
470        assert!(!non.is_boundary);
471        assert!(non.inner.is_some());
472    }
473
474    #[test]
475    fn anchors_and_failure_slots() {
476        let slots = pattern_slots(r"\b", Flags::default()).expect("parses");
477        let non = expect_non_pairing(&slots[0]);
478        assert!(!non.is_boundary);
479        assert!(non.inner.is_none());
480        let slots = pattern_slots(r"(?!)", Flags::default()).expect("parses");
481        let non = expect_non_pairing(&slots[0]);
482        assert!(non.is_boundary);
483    }
484
485    #[test]
486    fn sequence_to_alternatives_splits_top_level_branches() {
487        let (ops, _) = crate::redos::ast::parse("a|b", Flags::default()).expect("parses");
488        let alternatives = sequence_to_alternatives(&ops, Flags::default());
489        assert_eq!(alternatives.len(), 2);
490        let (ops, _) = crate::redos::ast::parse("ab", Flags::default()).expect("parses");
491        let alternatives = sequence_to_alternatives(&ops, Flags::default());
492        assert_eq!(alternatives.len(), 1);
493    }
494
495    #[test]
496    fn printable_alphabet_matches_the_reference_layout() {
497        // Digits, then letters, then punctuation, then the whitespace run.
498        assert_eq!(PRINTABLE.first(), Some(&'0'));
499        assert_eq!(PRINTABLE[10], 'a');
500        assert_eq!(PRINTABLE[36], 'A');
501        assert_eq!(PRINTABLE[62], '!');
502        assert_eq!(PRINTABLE.last(), Some(&'\x0c'));
503        assert_eq!(PRINTABLE.len(), 100);
504    }
505
506    #[test]
507    fn node_intervals_handles_category_nodes_directly() {
508        let intervals = node_intervals(&Op::Category(Category::Digit), Flags::default());
509        assert!(intervals.contains(u32::from('5')));
510        assert!(!intervals.contains(u32::from('x')));
511    }
512
513    #[test]
514    fn node_intervals_folds_not_literal_under_ignorecase() {
515        let flags = Flags {
516            ignorecase: true,
517            ..Flags::default()
518        };
519        let intervals = node_intervals(&Op::NotLiteral(u32::from('a')), flags);
520        assert!(!intervals.contains(u32::from('a')));
521        assert!(!intervals.contains(u32::from('A')));
522    }
523
524    #[test]
525    fn node_intervals_literal_folds_under_ignorecase() {
526        let flags = Flags {
527            ignorecase: true,
528            ..Flags::default()
529        };
530        let intervals = node_intervals(&Op::Literal(u32::from('a')), flags);
531        assert!(intervals.contains(u32::from('A')));
532    }
533
534    #[test]
535    fn ascii_flag_narrows_the_digit_category() {
536        let flags = Flags {
537            ascii: true,
538            ..Flags::default()
539        };
540        let intervals = node_intervals(&Op::Category(Category::Digit), flags);
541        assert!(intervals.contains(u32::from('5')));
542        assert!(!intervals.contains(0x0660));
543    }
544
545    #[test]
546    fn in_intervals_negates_after_the_member_union() {
547        let items = vec![ClassItem::Negate, ClassItem::Literal(u32::from('a'))];
548        let intervals = in_intervals(&items, Flags::default());
549        assert!(intervals.contains(u32::from('b')));
550        assert!(!intervals.contains(u32::from('a')));
551    }
552
553    #[test]
554    #[should_panic(expected = "expected pairing")]
555    fn expect_pairing_rejects_other_variants() {
556        let _ = expect_pairing(&Slot::NonPairing(NonPairingSlot {
557            is_boundary: false,
558            inner: None,
559            unbounded: false,
560            max_repeat: None,
561            variable_bounded: false,
562        }));
563    }
564
565    #[test]
566    #[should_panic(expected = "expected non-pairing")]
567    fn expect_non_pairing_rejects_other_variants() {
568        let _ = expect_non_pairing(&Slot::Pairing(PairingAtom {
569            intervals: IntervalSet::empty(),
570            allows_zero: false,
571            unbounded: false,
572            max_repeat: None,
573            variable_bounded: false,
574        }));
575    }
576
577    #[test]
578    fn ascii_flags_pick_the_ascii_category_tables() {
579        let ascii = crate::redos::ast::Flags {
580            ascii: true,
581            ..Flags::default()
582        };
583        for pattern in [r"\d", r"\s", r"\w", r"\D", r"\S", r"\W"] {
584            let parsed = pattern_slots(pattern, ascii).expect("parses");
585            assert_eq!(parsed.len(), 1, "{pattern}");
586            let atom = expect_pairing(&parsed[0]);
587            assert!(!atom.intervals.is_empty(), "{pattern}");
588        }
589    }
590
591    #[test]
592    fn negated_categories_complement_the_unicode_tables() {
593        for pattern in [r"\D", r"\S", r"\W"] {
594            let parsed = pattern_slots(pattern, Flags::default()).expect("parses");
595            assert_eq!(parsed.len(), 1, "{pattern}");
596            let atom = expect_pairing(&parsed[0]);
597            assert!(!atom.intervals.is_empty(), "{pattern}");
598        }
599    }
600
601    #[test]
602    fn negated_classes_start_from_an_empty_set() {
603        // The leading Negate item contributes no intervals of its own.
604        let parsed = pattern_slots(r"[^ab]", Flags::default()).expect("parses");
605        assert_eq!(parsed.len(), 1);
606    }
607
608    #[test]
609    fn node_intervals_of_non_class_ops_is_empty() {
610        assert_eq!(
611            node_intervals(&Op::At(crate::redos::ast::At::Beginning), Flags::default()),
612            IntervalSet::empty()
613        );
614        assert_eq!(
615            node_intervals(&Op::GroupRef(1), Flags::default()),
616            IntervalSet::empty()
617        );
618    }
619}