Skip to main content

brokk_bifrost_python/
regex.rs

1//! Python `re` pattern-value facts.
2//!
3//! Source structure comes from tree-sitter-python. This module decodes only a
4//! string AST node selected as the pattern argument of a recognized `re` API,
5//! then parses that decoded value with Python `re` rules. Unsupported syntax
6//! is emitted as an incomplete fact instead of being treated as harmless.
7
8use crate::bindings::{
9    PythonLexicalNameResolution, python_comprehension_binds_name_at,
10    python_module_or_class_scope_binds_name_bounded, python_name_resolution_at,
11};
12use crate::syntax::python_static_attribute_path;
13use brokk_bifrost_core::analyzer::Range;
14use brokk_bifrost_core::analyzer::structural::kinds::NormalizedKind;
15use brokk_bifrost_core::analyzer::structural::spec::EmbeddedLeafFact;
16use brokk_bifrost_core::text_utils::{compute_line_starts, find_line_index_for_offset};
17use std::collections::{HashMap, HashSet, VecDeque};
18use tree_sitter::Node;
19
20const MAX_PATTERN_CHARS: usize = 8192;
21const MAX_PATTERN_FACTS: usize = 4096;
22const MAX_GROUP_DEPTH: usize = 128;
23const MAX_NFA_STATES: usize = 512;
24const MAX_NFA_PRODUCT_PAIRS: usize = 65_536;
25const MAX_NFA_REPEAT: u32 = 32;
26const MAX_REDOSE_GROUPS: usize = 16;
27const MAX_REDOSE_ALTERNATIVES: usize = 8;
28
29#[derive(Debug)]
30struct MappedChar {
31    source_start: usize,
32    source_end: usize,
33}
34
35#[derive(Debug)]
36struct DecodedValue {
37    value: String,
38    chars: Vec<MappedChar>,
39    source_range: Range,
40    is_bytes: bool,
41}
42
43#[derive(Debug, Clone, Copy)]
44struct RegexFlags {
45    dotall: bool,
46    multiline: bool,
47    ignorecase: bool,
48    ascii: bool,
49    verbose: bool,
50    complete: bool,
51}
52
53#[derive(Debug, Clone, Copy)]
54struct Api {
55    flags_position: usize,
56    scans_from_each_offset: bool,
57}
58
59#[derive(Debug, Clone)]
60struct Fact {
61    kind: NormalizedKind,
62    start: usize,
63    end: usize,
64    parent: Option<usize>,
65    construct: Option<String>,
66    first_set: Option<CharacterSet>,
67    // Singleton escape value before case folding; retained only for this pattern.
68    escaped_literal: Option<char>,
69    quantifier: Option<Quantifier>,
70    minimum_width: Option<u32>,
71}
72
73fn regex_sequence_branches(
74    facts: &[Fact],
75    children: impl IntoIterator<Item = usize>,
76) -> Vec<Vec<usize>> {
77    let mut branches = vec![Vec::new()];
78    for child in children {
79        if facts[child].kind == NormalizedKind::RegexAlternation {
80            branches.push(Vec::new());
81        } else {
82            branches
83                .last_mut()
84                .expect("a regex sequence has a branch")
85                .push(child);
86        }
87    }
88    branches
89}
90
91fn polynomial_atoms_overlap(left: &Fact, right: &Fact) -> bool {
92    match (&left.first_set, &right.first_set) {
93        (Some(left), Some(right)) => left.intersects(right),
94        _ => matches!(
95            (left.construct.as_deref(), right.construct.as_deref()),
96            (Some("category:d"), Some("category:d" | "category:w"))
97                | (Some("category:w"), Some("category:d" | "category:w"))
98                | (Some("category:s"), Some("category:s"))
99        ),
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104struct CharacterSet {
105    ranges: Vec<(u32, u32)>,
106}
107
108impl CharacterSet {
109    fn one(value: char) -> Self {
110        let value = value as u32;
111        Self {
112            ranges: vec![(value, value)],
113        }
114    }
115
116    fn any_except_newline() -> Self {
117        Self {
118            ranges: vec![(0, 9), (11, 0x10ffff)],
119        }
120    }
121
122    fn complement(mut self) -> Self {
123        let mut ranges = Vec::new();
124        let mut next = 0u32;
125        self.ranges.sort_unstable();
126        for (start, end) in self.ranges {
127            if next < start {
128                ranges.push((next, start - 1));
129            }
130            next = end.saturating_add(1);
131            if next > 0x10ffff {
132                break;
133            }
134        }
135        if next <= 0x10ffff {
136            ranges.push((next, 0x10ffff));
137        }
138        Self { ranges }
139    }
140
141    fn union_with(&mut self, other: &Self) {
142        self.ranges.extend_from_slice(&other.ranges);
143        self.ranges.sort_unstable();
144        let mut merged: Vec<(u32, u32)> = Vec::with_capacity(self.ranges.len());
145        for (start, end) in self.ranges.drain(..) {
146            if let Some((_, previous_end)) = merged.last_mut()
147                && start <= previous_end.saturating_add(1)
148            {
149                *previous_end = (*previous_end).max(end);
150            } else {
151                merged.push((start, end));
152            }
153        }
154        self.ranges = merged;
155    }
156
157    fn intersects(&self, other: &Self) -> bool {
158        self.ranges.iter().any(|(left_start, left_end)| {
159            other
160                .ranges
161                .iter()
162                .any(|(right_start, right_end)| left_start <= right_end && right_start <= left_end)
163        })
164    }
165
166    fn contains(&self, character: char) -> bool {
167        let value = character as u32;
168        self.ranges
169            .iter()
170            .any(|(start, end)| *start <= value && value <= *end)
171    }
172
173    fn with_ascii_case_variants(mut self) -> Self {
174        for lower in 'a'..='z' {
175            let upper = lower.to_ascii_uppercase();
176            match (self.contains(lower), self.contains(upper)) {
177                (true, false) => self.union_with(&Self::one(upper)),
178                (false, true) => self.union_with(&Self::one(lower)),
179                _ => {}
180            }
181        }
182        self
183    }
184}
185
186#[derive(Debug, Clone, Default)]
187struct NfaState {
188    epsilon: Vec<usize>,
189    transitions: Vec<(CharacterSet, usize)>,
190    capture_ends: Vec<usize>,
191}
192
193#[derive(Debug, Clone)]
194struct NfaFragment {
195    start: usize,
196    end: usize,
197    states: Vec<usize>,
198}
199
200#[derive(Debug)]
201struct RegexNfa {
202    states: Vec<NfaState>,
203    start: usize,
204    end: usize,
205}
206
207#[derive(Debug, Clone, Copy)]
208enum NfaError {
209    Unsupported,
210    Budget,
211}
212
213impl RegexNfa {
214    fn compile(
215        facts: &[Fact],
216        root: usize,
217        selected_branch: Option<usize>,
218    ) -> Result<Self, NfaError> {
219        let mut children = vec![Vec::new(); facts.len()];
220        for (id, fact) in facts.iter().enumerate() {
221            if let Some(parent) = fact.parent {
222                children[parent].push(id);
223            }
224        }
225        let mut capture_ids = vec![None; facts.len()];
226        let mut next_capture = 1;
227        for (id, fact) in facts.iter().enumerate() {
228            if fact.kind == NormalizedKind::RegexGroup
229                && matches!(
230                    fact.construct.as_deref(),
231                    Some("capturing" | "named-capturing")
232                )
233            {
234                capture_ids[id] = Some(next_capture);
235                next_capture += 1;
236            }
237        }
238        let mut builder = NfaBuilder {
239            facts,
240            children,
241            selected_branch: selected_branch.map(|branch| (root, branch)),
242            capture_ids: &capture_ids,
243            states: Vec::new(),
244        };
245        let mut fragments = vec![None; facts.len()];
246        let mut stack = vec![(root, false)];
247        while let Some((id, expanded)) = stack.pop() {
248            if fragments[id].is_some() {
249                continue;
250            }
251            if expanded {
252                fragments[id] = Some(builder.compile_fact(id, &fragments)?);
253                continue;
254            }
255            stack.push((id, true));
256            let dependencies = builder.dependencies(id);
257            for dependency in dependencies.into_iter().rev() {
258                stack.push((dependency, false));
259            }
260        }
261        let fragment = fragments[root].take().ok_or(NfaError::Unsupported)?;
262        Ok(Self {
263            states: builder.states,
264            start: fragment.start,
265            end: fragment.end,
266        })
267    }
268
269    fn match_prefix_capture_groups(&self, sample: &str) -> (bool, HashSet<usize>) {
270        let chars = sample.chars().collect::<Vec<_>>();
271        let width = chars.len() + 1;
272        let node_id = |state: usize, position: usize| state * width + position;
273        let mut reachable = vec![false; self.states.len() * width];
274        let mut reverse = vec![Vec::new(); self.states.len() * width];
275        let start = node_id(self.start, 0);
276        reachable[start] = true;
277        let mut pending = VecDeque::from([start]);
278        while let Some(node) = pending.pop_front() {
279            let state = node / width;
280            let position = node % width;
281            let mut successors = self.states[state]
282                .epsilon
283                .iter()
284                .map(|&target| node_id(target, position))
285                .collect::<Vec<_>>();
286            if let Some(&character) = chars.get(position) {
287                successors.extend(
288                    self.states[state]
289                        .transitions
290                        .iter()
291                        .filter(|(set, _)| set.contains(character))
292                        .map(|(_, target)| node_id(*target, position + 1)),
293                );
294            }
295            for successor in successors {
296                reverse[successor].push(node);
297                if !std::mem::replace(&mut reachable[successor], true) {
298                    pending.push_back(successor);
299                }
300            }
301        }
302
303        let accepting = (0..width)
304            .map(|position| node_id(self.end, position))
305            .filter(|&node| reachable[node])
306            .collect::<Vec<_>>();
307        if accepting.is_empty() {
308            return (false, HashSet::new());
309        }
310        let mut reaches_accept = vec![false; reachable.len()];
311        let mut pending = VecDeque::new();
312        for node in accepting {
313            reaches_accept[node] = true;
314            pending.push_back(node);
315        }
316        while let Some(node) = pending.pop_front() {
317            for &previous in &reverse[node] {
318                if !std::mem::replace(&mut reaches_accept[previous], true) {
319                    pending.push_back(previous);
320                }
321            }
322        }
323        let groups = self
324            .states
325            .iter()
326            .enumerate()
327            .filter(|(state, _)| {
328                (0..width).any(|position| {
329                    let node = node_id(*state, position);
330                    reachable[node] && reaches_accept[node]
331                })
332            })
333            .flat_map(|(_, state)| state.capture_ends.iter().copied())
334            .collect();
335        (true, groups)
336    }
337
338    fn intersects_on_nonempty_string(
339        &self,
340        other: &Self,
341        remaining_budget: &mut usize,
342    ) -> Result<bool, NfaError> {
343        let mut pending = VecDeque::from([(self.start, other.start, false)]);
344        let mut seen = HashSet::new();
345        while let Some((left, right, consumed)) = pending.pop_front() {
346            if !seen.insert((left, right, consumed)) {
347                continue;
348            }
349            if *remaining_budget == 0 {
350                return Err(NfaError::Budget);
351            }
352            *remaining_budget -= 1;
353            if consumed && left == self.end && right == other.end {
354                return Ok(true);
355            }
356            for &target in &self.states[left].epsilon {
357                pending.push_back((target, right, consumed));
358            }
359            for &target in &other.states[right].epsilon {
360                pending.push_back((left, target, consumed));
361            }
362            for (left_set, left_target) in &self.states[left].transitions {
363                for (right_set, right_target) in &other.states[right].transitions {
364                    if left_set.intersects(right_set) {
365                        pending.push_back((*left_target, *right_target, true));
366                    }
367                }
368            }
369        }
370        Ok(false)
371    }
372
373    fn has_repartitioned_prefix(
374        &self,
375        longer: &Self,
376        repeated: &Self,
377        remaining_budget: &mut usize,
378    ) -> Result<bool, NfaError> {
379        // Find a non-empty common prefix accepted by `self` while `longer`
380        // continues, then require that continuation to match the repeated
381        // alternation. This catches ambiguous partitions such as `__|.`.
382        let mut pending = VecDeque::from([(0u8, self.start, longer.start, false)]);
383        let mut seen = HashSet::new();
384        while let Some((phase, left, right, consumed)) = pending.pop_front() {
385            if !seen.insert((phase, left, right, consumed)) {
386                continue;
387            }
388            if *remaining_budget == 0 {
389                return Err(NfaError::Budget);
390            }
391            *remaining_budget -= 1;
392            if phase == 0 {
393                if consumed && left == self.end {
394                    pending.push_back((1, right, repeated.start, false));
395                }
396                for &target in &self.states[left].epsilon {
397                    pending.push_back((0, target, right, consumed));
398                }
399                for &target in &longer.states[right].epsilon {
400                    pending.push_back((0, left, target, consumed));
401                }
402                for (left_set, left_target) in &self.states[left].transitions {
403                    for (right_set, right_target) in &longer.states[right].transitions {
404                        if left_set.intersects(right_set) {
405                            pending.push_back((0, *left_target, *right_target, true));
406                        }
407                    }
408                }
409            } else {
410                if consumed && left == longer.end && right == repeated.end {
411                    return Ok(true);
412                }
413                for &target in &longer.states[left].epsilon {
414                    pending.push_back((1, target, right, consumed));
415                }
416                for &target in &repeated.states[right].epsilon {
417                    pending.push_back((1, left, target, consumed));
418                }
419                for (longer_set, longer_target) in &longer.states[left].transitions {
420                    for (repeated_set, repeated_target) in &repeated.states[right].transitions {
421                        if longer_set.intersects(repeated_set) {
422                            pending.push_back((1, *longer_target, *repeated_target, true));
423                        }
424                    }
425                }
426            }
427        }
428        Ok(false)
429    }
430
431    fn has_pumpable_fork(&self, remaining_budget: &mut usize) -> Result<bool, NfaError> {
432        let epsilon_closures = self.epsilon_closures();
433        for fork in 0..self.states.len() {
434            let mut outgoing = Vec::new();
435            for &source in &epsilon_closures[fork] {
436                for (set, target) in &self.states[source].transitions {
437                    outgoing.push((source, set, *target));
438                }
439            }
440            for left in 0..outgoing.len() {
441                for right in left + 1..outgoing.len() {
442                    if *remaining_budget == 0 {
443                        return Err(NfaError::Budget);
444                    }
445                    *remaining_budget -= 1;
446                    let (left_source, left_set, left_target) = outgoing[left];
447                    let (right_source, right_set, right_target) = outgoing[right];
448                    if left_source == right_source
449                        && left_target == right_target
450                        && left_set.ranges == right_set.ranges
451                    {
452                        continue;
453                    }
454                    if !left_set.intersects(right_set) {
455                        continue;
456                    }
457                    if self.product_reaches_fork(
458                        (left_target, right_target),
459                        fork,
460                        &epsilon_closures,
461                        remaining_budget,
462                    )? {
463                        return Ok(true);
464                    }
465                }
466            }
467        }
468        Ok(false)
469    }
470
471    fn epsilon_closures(&self) -> Vec<Vec<usize>> {
472        (0..self.states.len())
473            .map(|start| {
474                let mut seen = vec![false; self.states.len()];
475                let mut pending = vec![start];
476                let mut closure = Vec::new();
477                while let Some(state) = pending.pop() {
478                    if std::mem::replace(&mut seen[state], true) {
479                        continue;
480                    }
481                    closure.push(state);
482                    pending.extend(self.states[state].epsilon.iter().copied());
483                }
484                closure
485            })
486            .collect()
487    }
488
489    fn product_reaches_fork(
490        &self,
491        start: (usize, usize),
492        fork: usize,
493        epsilon_closures: &[Vec<usize>],
494        remaining_budget: &mut usize,
495    ) -> Result<bool, NfaError> {
496        let canonical = |(left, right): (usize, usize)| {
497            if left <= right {
498                (left, right)
499            } else {
500                (right, left)
501            }
502        };
503        let mut pending = VecDeque::from([canonical(start)]);
504        let mut seen = HashSet::new();
505        while let Some((left, right)) = pending.pop_front() {
506            if (left, right) == (fork, fork) {
507                return Ok(true);
508            }
509            if !seen.insert((left, right)) {
510                continue;
511            }
512            for &left_source in &epsilon_closures[left] {
513                for (left_set, left_target) in &self.states[left_source].transitions {
514                    for &right_source in &epsilon_closures[right] {
515                        for (right_set, right_target) in &self.states[right_source].transitions {
516                            if *remaining_budget == 0 {
517                                return Err(NfaError::Budget);
518                            }
519                            *remaining_budget -= 1;
520                            if left_set.intersects(right_set) {
521                                pending.push_back(canonical((*left_target, *right_target)));
522                            }
523                        }
524                    }
525                }
526            }
527        }
528        Ok(false)
529    }
530
531    fn positive_closure(mut self) -> Result<Self, NfaError> {
532        if self.states.len().saturating_add(2) > MAX_NFA_STATES {
533            return Err(NfaError::Budget);
534        }
535        let original_start = self.start;
536        let original_end = self.end;
537        let start = self.states.len();
538        let end = start + 1;
539        self.states.push(NfaState::default());
540        self.states.push(NfaState::default());
541        self.states[start].epsilon.push(original_start);
542        self.states[original_end]
543            .epsilon
544            .extend([end, original_start]);
545        self.start = start;
546        self.end = end;
547        Ok(self)
548    }
549}
550
551struct NfaBuilder<'a> {
552    facts: &'a [Fact],
553    children: Vec<Vec<usize>>,
554    selected_branch: Option<(usize, usize)>,
555    capture_ids: &'a [Option<usize>],
556    states: Vec<NfaState>,
557}
558
559impl NfaBuilder<'_> {
560    fn dependencies(&self, id: usize) -> Vec<usize> {
561        match self.facts[id].kind {
562            NormalizedKind::RegexPattern | NormalizedKind::RegexGroup => self.children[id]
563                .iter()
564                .copied()
565                .filter(|child| self.facts[*child].kind == NormalizedKind::RegexSequence)
566                .collect(),
567            NormalizedKind::RegexSequence => self.children[id]
568                .iter()
569                .copied()
570                .filter(|child| {
571                    !matches!(
572                        self.facts[*child].kind,
573                        NormalizedKind::RegexAlternation | NormalizedKind::RegexQuantifier
574                    )
575                })
576                .collect(),
577            _ => Vec::new(),
578        }
579    }
580
581    fn compile_fact(
582        &mut self,
583        id: usize,
584        fragments: &[Option<NfaFragment>],
585    ) -> Result<NfaFragment, NfaError> {
586        let fact = &self.facts[id];
587        let kind = fact.kind;
588        let is_comment = fact.construct.as_deref() == Some("comment");
589        let first_set = fact.first_set.clone();
590        let base = match kind {
591            NormalizedKind::RegexPattern | NormalizedKind::RegexGroup => {
592                if is_comment {
593                    self.empty_fragment()?
594                } else if kind == NormalizedKind::RegexGroup
595                    && fact
596                        .construct
597                        .as_deref()
598                        .is_some_and(|construct| construct.starts_with("look"))
599                {
600                    return Err(NfaError::Unsupported);
601                } else {
602                    let sequence = self.children[id]
603                        .iter()
604                        .copied()
605                        .find(|child| self.facts[*child].kind == NormalizedKind::RegexSequence)
606                        .ok_or(NfaError::Unsupported)?;
607                    fragments[sequence].clone().ok_or(NfaError::Unsupported)?
608                }
609            }
610            NormalizedKind::RegexSequence => self.compile_sequence(id, fragments)?,
611            NormalizedKind::RegexAnchor => return Err(NfaError::Unsupported),
612            NormalizedKind::RegexLiteral
613            | NormalizedKind::RegexWildcard
614            | NormalizedKind::RegexCharacterClass
615            | NormalizedKind::RegexCharacterRange
616            | NormalizedKind::RegexEscape => {
617                let set = first_set.ok_or(NfaError::Unsupported)?;
618                self.character_fragment(set)?
619            }
620            _ => return Err(NfaError::Unsupported),
621        };
622        let base = if let Some(capture) = self.capture_ids[id] {
623            self.capture_group(base, capture)?
624        } else {
625            base
626        };
627        self.apply_quantifier(id, base)
628    }
629
630    fn capture_group(
631        &mut self,
632        mut inner: NfaFragment,
633        capture: usize,
634    ) -> Result<NfaFragment, NfaError> {
635        let start = self.new_state()?;
636        let end = self.new_state()?;
637        self.states[start].epsilon.push(inner.start);
638        self.states[inner.end].epsilon.push(end);
639        self.states[end].capture_ends.push(capture);
640        inner.states.extend([start, end]);
641        inner.start = start;
642        inner.end = end;
643        Ok(inner)
644    }
645
646    fn compile_sequence(
647        &mut self,
648        sequence: usize,
649        fragments: &[Option<NfaFragment>],
650    ) -> Result<NfaFragment, NfaError> {
651        let branches = regex_sequence_branches(self.facts, self.children[sequence].iter().copied())
652            .into_iter()
653            .map(|terms| {
654                let terms = terms
655                    .into_iter()
656                    .filter(|&child| self.facts[child].kind != NormalizedKind::RegexQuantifier)
657                    .map(|child| fragments[child].clone().ok_or(NfaError::Unsupported))
658                    .collect::<Result<Vec<_>, _>>()?;
659                self.concatenate(terms)
660            })
661            .collect::<Result<Vec<_>, _>>()?;
662        if let Some((selected_sequence, selected_index)) = self.selected_branch
663            && selected_sequence == sequence
664        {
665            return branches
666                .get(selected_index)
667                .cloned()
668                .ok_or(NfaError::Unsupported);
669        }
670        self.alternate(branches)
671    }
672
673    fn apply_quantifier(&mut self, id: usize, base: NfaFragment) -> Result<NfaFragment, NfaError> {
674        let Some(quantifier_id) = self.children[id]
675            .iter()
676            .copied()
677            .find(|child| self.facts[*child].kind == NormalizedKind::RegexQuantifier)
678        else {
679            return Ok(base);
680        };
681        let quantifier = self.facts[quantifier_id]
682            .quantifier
683            .ok_or(NfaError::Unsupported)?;
684        if quantifier.minimum > MAX_NFA_REPEAT
685            || quantifier
686                .maximum
687                .is_some_and(|maximum| maximum > MAX_NFA_REPEAT)
688        {
689            return Err(NfaError::Budget);
690        }
691        let mut result = if quantifier.minimum == 0 {
692            self.empty_fragment()?
693        } else {
694            base.clone()
695        };
696        let mut copies = Vec::new();
697        for _ in 1..quantifier.minimum {
698            copies.push(self.clone_fragment(&base)?);
699        }
700        let unbounded = match quantifier.maximum {
701            Some(maximum) => {
702                for _ in quantifier.minimum..maximum {
703                    let clone = self.clone_fragment(&base)?;
704                    copies.push(self.optional(clone)?);
705                }
706                None
707            }
708            None => {
709                let clone = self.clone_fragment(&base)?;
710                Some(self.star(clone)?)
711            }
712        };
713        for copy in copies {
714            result = self.concatenate(vec![result, copy])?;
715        }
716        if let Some(unbounded) = unbounded {
717            result = self.concatenate(vec![result, unbounded])?;
718        }
719        Ok(result)
720    }
721
722    fn new_state(&mut self) -> Result<usize, NfaError> {
723        if self.states.len() >= MAX_NFA_STATES {
724            return Err(NfaError::Budget);
725        }
726        let state = self.states.len();
727        self.states.push(NfaState::default());
728        Ok(state)
729    }
730
731    fn empty_fragment(&mut self) -> Result<NfaFragment, NfaError> {
732        let start = self.new_state()?;
733        let end = self.new_state()?;
734        self.states[start].epsilon.push(end);
735        Ok(NfaFragment {
736            start,
737            end,
738            states: vec![start, end],
739        })
740    }
741
742    fn character_fragment(&mut self, set: CharacterSet) -> Result<NfaFragment, NfaError> {
743        let start = self.new_state()?;
744        let end = self.new_state()?;
745        self.states[start].transitions.push((set, end));
746        Ok(NfaFragment {
747            start,
748            end,
749            states: vec![start, end],
750        })
751    }
752
753    fn concatenate(&mut self, mut fragments: Vec<NfaFragment>) -> Result<NfaFragment, NfaError> {
754        let Some(mut result) = fragments.pop() else {
755            return self.empty_fragment();
756        };
757        while let Some(next) = fragments.pop() {
758            self.states[next.end].epsilon.push(result.start);
759            result.start = next.start;
760            result.states.extend(next.states);
761        }
762        Ok(result)
763    }
764
765    fn alternate(&mut self, fragments: Vec<NfaFragment>) -> Result<NfaFragment, NfaError> {
766        if fragments.len() == 1 {
767            return Ok(fragments
768                .into_iter()
769                .next()
770                .expect("one alternative exists"));
771        }
772        let start = self.new_state()?;
773        let end = self.new_state()?;
774        let mut states = vec![start, end];
775        for fragment in fragments {
776            self.states[start].epsilon.push(fragment.start);
777            self.states[fragment.end].epsilon.push(end);
778            states.extend(fragment.states);
779        }
780        Ok(NfaFragment { start, end, states })
781    }
782
783    fn optional(&mut self, inner: NfaFragment) -> Result<NfaFragment, NfaError> {
784        let start = self.new_state()?;
785        let end = self.new_state()?;
786        self.states[start].epsilon.extend([end, inner.start]);
787        self.states[inner.end].epsilon.push(end);
788        let mut states = vec![start, end];
789        states.extend(inner.states);
790        Ok(NfaFragment { start, end, states })
791    }
792
793    fn star(&mut self, inner: NfaFragment) -> Result<NfaFragment, NfaError> {
794        let start = self.new_state()?;
795        let end = self.new_state()?;
796        self.states[start].epsilon.extend([end, inner.start]);
797        self.states[inner.end].epsilon.extend([end, inner.start]);
798        let mut states = vec![start, end];
799        states.extend(inner.states);
800        Ok(NfaFragment { start, end, states })
801    }
802
803    fn clone_fragment(&mut self, fragment: &NfaFragment) -> Result<NfaFragment, NfaError> {
804        let mut remap = vec![usize::MAX; self.states.len()];
805        for &old in &fragment.states {
806            remap[old] = self.new_state()?;
807        }
808        for &old in &fragment.states {
809            let new = remap[old];
810            let old_state = self.states[old].clone();
811            for target in old_state.epsilon {
812                let Some(mapped) = remap
813                    .get(target)
814                    .copied()
815                    .filter(|&mapped| mapped != usize::MAX)
816                else {
817                    return Err(NfaError::Unsupported);
818                };
819                self.states[new].epsilon.push(mapped);
820            }
821            for (set, target) in old_state.transitions {
822                let Some(mapped) = remap
823                    .get(target)
824                    .copied()
825                    .filter(|&mapped| mapped != usize::MAX)
826                else {
827                    return Err(NfaError::Unsupported);
828                };
829                self.states[new].transitions.push((set, mapped));
830            }
831        }
832        Ok(NfaFragment {
833            start: remap[fragment.start],
834            end: remap[fragment.end],
835            states: fragment.states.iter().map(|state| remap[*state]).collect(),
836        })
837    }
838}
839
840#[derive(Debug, Clone)]
841struct BranchFrame {
842    owner: usize,
843    sequence: usize,
844    group_start: Option<usize>,
845    last_atom: Option<usize>,
846    restore_flags: Option<RegexFlags>,
847}
848
849#[derive(Debug, Clone, Copy)]
850struct Quantifier {
851    minimum: u32,
852    maximum: Option<u32>,
853    possessive: bool,
854}
855
856#[derive(Debug)]
857struct ClassAtom {
858    start: usize,
859    end: usize,
860    value: Option<char>,
861    set: Option<CharacterSet>,
862    kind: NormalizedKind,
863    construct: Option<String>,
864    backspace: bool,
865    incomplete: Option<&'static str>,
866}
867
868#[derive(Debug)]
869struct EscapeValue {
870    end: usize,
871    anchor: bool,
872    construct: Option<String>,
873    first_set: Option<CharacterSet>,
874    incomplete: Option<&'static str>,
875    backspace: bool,
876}
877
878/// Return facts for a string-literal node or a call node in the Python AST.
879/// A string is analyzed only when it is the first positional argument or the
880/// `pattern` keyword of a statically named `re` API call.
881pub(crate) fn embedded_facts(node: Node<'_>, source: &str) -> Vec<EmbeddedLeafFact> {
882    if node.kind() == "call" {
883        return dynamic_pattern_fact(node, source).into_iter().collect();
884    }
885    if !matches!(node.kind(), "string" | "concatenated_string")
886        || (node.kind() == "string"
887            && node
888                .parent()
889                .is_some_and(|parent| parent.kind() == "concatenated_string"))
890    {
891        return Vec::new();
892    }
893    let Some((call, api)) = pattern_call(node, source) else {
894        return Vec::new();
895    };
896    let decoded = match decode_regex_pattern_string(node, source) {
897        Ok(decoded) => decoded,
898        Err(reason) => return vec![incomplete_fact(node, source, reason)],
899    };
900    let flags = call_flags(call, api, source);
901    let facts = RegexParser::new(&decoded, flags, api).parse();
902    facts_to_embedded(facts, &decoded, source)
903}
904
905pub(crate) fn module_assigned_facts(root: Node<'_>, source: &str) -> Vec<EmbeddedLeafFact> {
906    assert_eq!(root.kind(), "module");
907    let mut assignments = HashMap::<String, (usize, Option<Node<'_>>)>::new();
908    let mut calls = Vec::new();
909    let mut pending = vec![root];
910    while let Some(node) = pending.pop() {
911        if module_level_statement(node)
912            .is_some_and(|statement| statement.kind() == "expression_statement")
913            && node.kind() == "assignment"
914            && let (Some(left), Some(right)) = (
915                node.child_by_field_name("left"),
916                node.child_by_field_name("right"),
917            )
918            && left.kind() == "identifier"
919        {
920            let candidate =
921                matches!(right.kind(), "string" | "concatenated_string").then_some(right);
922            let entry = assignments
923                .entry(node_text(left, source).to_owned())
924                .or_insert((0, None));
925            entry.0 += 1;
926            entry.1 = (entry.0 == 1).then_some(candidate).flatten();
927        }
928        if node.kind() == "call"
929            && let Some(function) = node.child_by_field_name("function")
930            && let Some(api) = recognized_api(function, source)
931            && let Some(arguments) = node.child_by_field_name("arguments")
932            && let Some(pattern) = selected_pattern_argument(arguments, source)
933            && pattern.kind() == "identifier"
934        {
935            calls.push((node, pattern, api, node_text(pattern, source).to_owned()));
936        }
937        let mut cursor = node.walk();
938        let children = node.named_children(&mut cursor).collect::<Vec<_>>();
939        pending.extend(children.into_iter().rev());
940    }
941
942    let mut facts = Vec::new();
943    for (call, pattern, api, name) in calls {
944        let resolved = assignments
945            .get(&name)
946            .and_then(|(writes, candidate)| {
947                (*writes == 1)
948                    .then_some(*candidate)
949                    .flatten()
950                    .filter(|candidate| candidate.end_byte() < call.start_byte())
951            })
952            .filter(|_| module_pattern_binding_is_visible(&name, pattern, source));
953        let Some(selected) = resolved else {
954            let range = node_range(pattern, source);
955            facts.push(EmbeddedLeafFact {
956                kind: NormalizedKind::RegexIncomplete,
957                range,
958                parent: None,
959                construct: Some(format!("dynamic-pattern:{}", api.flags_position)),
960                occurrence_role: None,
961            });
962            continue;
963        };
964        let decoded = match decode_regex_pattern_string(selected, source) {
965            Ok(decoded) => decoded,
966            Err(reason) => {
967                facts.push(incomplete_fact(selected, source, reason));
968                continue;
969            }
970        };
971        let flags = call_flags(call, api, source);
972        let mut parsed = facts_to_embedded(
973            RegexParser::new(&decoded, flags, api).parse(),
974            &decoded,
975            source,
976        );
977        let base = facts.len();
978        for fact in &mut parsed {
979            fact.parent = fact.parent.map(|parent| base + parent);
980        }
981        facts.extend(parsed);
982    }
983    facts
984}
985
986fn module_pattern_binding_is_visible(name: &str, reference: Node<'_>, source: &str) -> bool {
987    if python_comprehension_binds_name_at(name, reference, source)
988        || matches!(
989            python_name_resolution_at(name, reference, source),
990            PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
991        )
992    {
993        return false;
994    }
995
996    let mut current = reference;
997    while let Some(parent) = current.parent() {
998        match parent.kind() {
999            "function_definition" | "lambda" => break,
1000            "class_definition" => {
1001                return python_module_or_class_scope_binds_name_bounded(
1002                    parent,
1003                    name,
1004                    source,
1005                    || true,
1006                ) == Some(false);
1007            }
1008            _ => current = parent,
1009        }
1010    }
1011    true
1012}
1013
1014fn module_level_statement<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
1015    let mut current = node;
1016    loop {
1017        let parent = current.parent()?;
1018        if parent.kind() == "module" {
1019            return Some(current);
1020        }
1021        current = parent;
1022    }
1023}
1024
1025fn incomplete_fact(node: Node<'_>, source: &str, reason: &str) -> EmbeddedLeafFact {
1026    let range = node_range(node, source);
1027    EmbeddedLeafFact {
1028        kind: NormalizedKind::RegexIncomplete,
1029        range,
1030        parent: None,
1031        construct: Some(reason.to_owned()),
1032        occurrence_role: None,
1033    }
1034}
1035
1036fn dynamic_pattern_fact(node: Node<'_>, source: &str) -> Option<EmbeddedLeafFact> {
1037    let function = node.child_by_field_name("function")?;
1038    let api = recognized_api(function, source)?;
1039    let arguments = node.child_by_field_name("arguments")?;
1040    let pattern = selected_pattern_argument(arguments, source)?;
1041    if matches!(pattern.kind(), "string" | "concatenated_string") {
1042        return None;
1043    }
1044    if pattern.kind() == "identifier" {
1045        return None;
1046    }
1047    let range = node_range(pattern, source);
1048    Some(EmbeddedLeafFact {
1049        kind: NormalizedKind::RegexIncomplete,
1050        range,
1051        parent: None,
1052        construct: Some(format!("dynamic-pattern:{}", api.flags_position)),
1053        occurrence_role: None,
1054    })
1055}
1056
1057fn pattern_call<'tree>(node: Node<'tree>, source: &str) -> Option<(Node<'tree>, Api)> {
1058    let argument = node.parent()?;
1059    let (arguments, is_pattern_keyword) = match argument.kind() {
1060        "keyword_argument" => {
1061            let name = argument.child_by_field_name("name")?;
1062            let value = argument.child_by_field_name("value")?;
1063            (
1064                argument.parent()?,
1065                value.id() == node.id() && node_text(name, source) == "pattern",
1066            )
1067        }
1068        "argument_list" => (argument, false),
1069        _ => return None,
1070    };
1071    if arguments.kind() != "argument_list" {
1072        return None;
1073    }
1074    let call = arguments.parent()?;
1075    if call.kind() != "call" {
1076        return None;
1077    }
1078    let function = call.child_by_field_name("function")?;
1079    let api = recognized_api(function, source)?;
1080    let selected = if is_pattern_keyword {
1081        true
1082    } else {
1083        selected_pattern_argument(arguments, source)
1084            .is_some_and(|pattern| pattern.id() == node.id())
1085    };
1086    selected.then_some((call, api))
1087}
1088
1089fn selected_pattern_argument<'tree>(arguments: Node<'tree>, source: &str) -> Option<Node<'tree>> {
1090    let mut positional = 0usize;
1091    let mut cursor = arguments.walk();
1092    for argument in arguments.named_children(&mut cursor) {
1093        match argument.kind() {
1094            "comment" => {}
1095            "keyword_argument" => {
1096                let name = argument.child_by_field_name("name")?;
1097                if node_text(name, source) == "pattern" {
1098                    return argument.child_by_field_name("value");
1099                }
1100            }
1101            _ => {
1102                if positional == 0 {
1103                    return Some(argument);
1104                }
1105                positional += 1;
1106            }
1107        }
1108    }
1109    None
1110}
1111
1112fn recognized_api(function: Node<'_>, source: &str) -> Option<Api> {
1113    let path = python_static_attribute_path(function)?;
1114    if path.len() != 2 || node_text(path[0], source) != "re" {
1115        return None;
1116    }
1117    let (flags_position, scans_from_each_offset) = match node_text(path[1], source) {
1118        "compile" => (1, false),
1119        "search" | "findall" | "finditer" => (2, true),
1120        "match" | "fullmatch" => (2, false),
1121        "split" => (3, true),
1122        "sub" | "subn" => (4, true),
1123        _ => return None,
1124    };
1125    Some(Api {
1126        flags_position,
1127        scans_from_each_offset,
1128    })
1129}
1130
1131fn call_flags(call: Node<'_>, api: Api, source: &str) -> RegexFlags {
1132    let mut flags_node = None;
1133    if let Some(arguments) = call.child_by_field_name("arguments") {
1134        let mut positional = 0usize;
1135        let mut cursor = arguments.walk();
1136        for argument in arguments.named_children(&mut cursor) {
1137            match argument.kind() {
1138                "comment" => {}
1139                "keyword_argument" => {
1140                    if argument
1141                        .child_by_field_name("name")
1142                        .is_some_and(|name| node_text(name, source) == "flags")
1143                    {
1144                        flags_node = argument.child_by_field_name("value");
1145                    }
1146                }
1147                _ => {
1148                    if positional == api.flags_position {
1149                        flags_node = Some(argument);
1150                    }
1151                    positional += 1;
1152                }
1153            }
1154        }
1155    }
1156    let Some(flags_node) = flags_node else {
1157        return RegexFlags {
1158            dotall: false,
1159            multiline: false,
1160            ignorecase: false,
1161            ascii: false,
1162            verbose: false,
1163            complete: true,
1164        };
1165    };
1166    let mut pending = vec![flags_node];
1167    let mut flags = RegexFlags {
1168        dotall: false,
1169        multiline: false,
1170        ignorecase: false,
1171        ascii: false,
1172        verbose: false,
1173        complete: true,
1174    };
1175    while let Some(node) = pending.pop() {
1176        match node.kind() {
1177            "integer" => {
1178                let text = node_text(node, source).replace('_', "");
1179                match python_integer_value(&text) {
1180                    Some(value) => {
1181                        flags.ignorecase |= value & 2 != 0;
1182                        flags.dotall |= value & 16 != 0;
1183                        flags.multiline |= value & 8 != 0;
1184                        flags.ascii |= value & 256 != 0;
1185                        flags.complete &=
1186                            value & !(2 | 8 | 16 | 32 | 256) == 0 && value & (64 | 128) == 0;
1187                    }
1188                    None => flags.complete = false,
1189                }
1190            }
1191            "attribute" | "identifier" => {
1192                let path = python_static_attribute_path(node);
1193                let name = path.as_ref().and_then(|path| {
1194                    (path.len() >= 2
1195                        && node_text(path[0], source) == "re"
1196                        && path[1..path.len() - 1]
1197                            .iter()
1198                            .all(|part| node_text(*part, source) == "re"))
1199                    .then(|| node_text(*path.last().expect("flag path is nonempty"), source))
1200                });
1201                match name {
1202                    Some("S" | "DOTALL") => flags.dotall = true,
1203                    Some("M" | "MULTILINE") => flags.multiline = true,
1204                    Some("I" | "IGNORECASE") => flags.ignorecase = true,
1205                    Some("A" | "ASCII") => flags.ascii = true,
1206                    Some("X" | "VERBOSE") => flags.verbose = true,
1207                    Some("U" | "UNICODE") => {}
1208                    Some("DEBUG") => flags.complete = false,
1209                    _ => flags.complete = false,
1210                }
1211            }
1212            "binary_operator" => {
1213                if let (Some(left), Some(right)) = (
1214                    node.child_by_field_name("left"),
1215                    node.child_by_field_name("right"),
1216                ) {
1217                    pending.push(right);
1218                    pending.push(left);
1219                } else {
1220                    flags.complete = false;
1221                }
1222            }
1223            _ => flags.complete = false,
1224        }
1225    }
1226    flags
1227}
1228
1229fn parse_inline_flags(
1230    chars: &[char],
1231    mut index: usize,
1232    mut flags: RegexFlags,
1233) -> Option<(usize, RegexFlags)> {
1234    let mut enabled = true;
1235    let mut saw_flag = false;
1236    let mut ascii_mode = None;
1237    while let Some(character) = chars.get(index).copied() {
1238        match character {
1239            ')' | ':' if saw_flag => {
1240                if ascii_mode == Some((true, true)) {
1241                    return None;
1242                }
1243                return Some((index, flags));
1244            }
1245            '-' if enabled && saw_flag => enabled = false,
1246            'i' | 'm' | 's' | 'x' | 'a' | 'u' => {
1247                saw_flag = true;
1248                match character {
1249                    'i' => flags.ignorecase = enabled,
1250                    'm' => flags.multiline = enabled,
1251                    's' => flags.dotall = enabled,
1252                    'x' => flags.verbose = enabled,
1253                    'a' => {
1254                        flags.ascii = enabled;
1255                        ascii_mode =
1256                            Some((enabled, ascii_mode.is_some_and(|(_, unicode)| unicode)));
1257                    }
1258                    'u' => {
1259                        flags.ascii = !enabled;
1260                        ascii_mode = Some((ascii_mode.is_some_and(|(ascii, _)| ascii), enabled));
1261                    }
1262                    _ => unreachable!("inline flag arm includes only Python regex flags"),
1263                }
1264            }
1265            _ => return None,
1266        }
1267        index += 1;
1268    }
1269    None
1270}
1271
1272fn python_integer_value(text: &str) -> Option<u64> {
1273    let (radix, digits) =
1274        if let Some(digits) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
1275            (16, digits)
1276        } else if let Some(digits) = text.strip_prefix("0o").or_else(|| text.strip_prefix("0O")) {
1277            (8, digits)
1278        } else if let Some(digits) = text.strip_prefix("0b").or_else(|| text.strip_prefix("0B")) {
1279            (2, digits)
1280        } else {
1281            (10, text)
1282        };
1283    u64::from_str_radix(digits, radix).ok()
1284}
1285
1286fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
1287    node.utf8_text(source.as_bytes())
1288        .expect("tree-sitter node ranges are valid UTF-8 source")
1289}
1290
1291fn node_range(node: Node<'_>, source: &str) -> Range {
1292    let line_starts = compute_line_starts(source);
1293    let start_byte = node.start_byte();
1294    let end_byte = node.end_byte();
1295    Range {
1296        start_byte,
1297        end_byte,
1298        start_line: find_line_index_for_offset(&line_starts, start_byte) + 1,
1299        end_line: find_line_index_for_offset(
1300            &line_starts,
1301            end_byte.saturating_sub(1).max(start_byte),
1302        ) + 1,
1303    }
1304}
1305
1306fn decode_python_string(node: Node<'_>, source: &str) -> Result<DecodedValue, &'static str> {
1307    let decoded = decode_string_literal(node, source)?;
1308    if decoded.is_bytes {
1309        return Err("non-text-or-formatted-string");
1310    }
1311    Ok(decoded)
1312}
1313
1314fn decode_regex_pattern_string(node: Node<'_>, source: &str) -> Result<DecodedValue, &'static str> {
1315    decode_string_literal(node, source)
1316}
1317
1318fn decode_string_literal(node: Node<'_>, source: &str) -> Result<DecodedValue, &'static str> {
1319    let mut strings = Vec::new();
1320    if node.kind() == "string" {
1321        strings.push(node);
1322    } else {
1323        let mut stack = vec![node];
1324        while let Some(current) = stack.pop() {
1325            if current.kind() == "string" {
1326                strings.push(current);
1327                continue;
1328            }
1329            let mut children = Vec::new();
1330            let mut cursor = current.walk();
1331            for child in current.named_children(&mut cursor) {
1332                if child.kind() == "concatenated_string" || child.kind() == "string" {
1333                    children.push(child);
1334                } else {
1335                    return Err("non-literal-string-segment");
1336                }
1337            }
1338            for child in children.into_iter().rev() {
1339                stack.push(child);
1340            }
1341        }
1342    }
1343    if strings.is_empty() {
1344        return Err("empty-concatenated-string");
1345    }
1346    let mut value = String::new();
1347    let mut chars = Vec::new();
1348    let mut byte_string = None;
1349    for string in strings {
1350        let is_bytes = decode_string_segment(string, source, &mut value, &mut chars)?;
1351        if byte_string.is_some_and(|previous| previous != is_bytes) {
1352            return Err("mixed-bytes-and-text-concatenation");
1353        }
1354        byte_string = Some(is_bytes);
1355        if chars.len() > MAX_PATTERN_CHARS {
1356            return Err("pattern-character-budget");
1357        }
1358    }
1359    Ok(DecodedValue {
1360        value,
1361        chars,
1362        source_range: node_range(node, source),
1363        is_bytes: byte_string.expect("non-empty literal expression has a string segment"),
1364    })
1365}
1366
1367/// Decode the value of a tree-sitter-selected Python string expression.
1368///
1369/// Callers must select the literal from syntax before invoking this helper;
1370/// this function classifies only the selected expression's value.
1371pub fn decode_python_string_value(node: Node<'_>, source: &str) -> Result<String, &'static str> {
1372    decode_python_string(node, source).map(|decoded| decoded.value)
1373}
1374
1375/// Decode each literal segment in a selected Python f-string, omitting its
1376/// interpolation expressions. The parse tree chooses the segments; this
1377/// helper reads only their decoded values.
1378pub fn decode_python_fstring_literal_segments(
1379    node: Node<'_>,
1380    source: &str,
1381) -> Result<Vec<String>, &'static str> {
1382    if node.kind() != "string" {
1383        return Err("not-a-python-fstring");
1384    }
1385    let mut start_token = None;
1386    let mut segments: Vec<Vec<Node<'_>>> = vec![Vec::new()];
1387    let mut cursor = node.walk();
1388    for child in node.named_children(&mut cursor) {
1389        match child.kind() {
1390            "string_start" if start_token.is_none() => start_token = Some(child),
1391            "string_start" => return Err("duplicate-fstring-start"),
1392            "string_end" => break,
1393            "interpolation" => segments.push(Vec::new()),
1394            "string_content" | "escape_sequence" => segments
1395                .last_mut()
1396                .expect("f-string literal segments retain a current segment")
1397                .push(child),
1398            _ => return Err("unsupported-fstring-prefix-node"),
1399        }
1400    }
1401    let start_token = start_token.ok_or("missing-fstring-start")?;
1402    let prefix = string_prefix(node_text(start_token, source));
1403    if !prefix.contains('f')
1404        || prefix
1405            .chars()
1406            .any(|character| !matches!(character, 'f' | 'r'))
1407        || prefix.matches('f').count() != 1
1408        || prefix.matches('r').count() > 1
1409    {
1410        return Err("unsupported-fstring-prefix");
1411    }
1412    let raw = prefix.contains('r');
1413    segments
1414        .into_iter()
1415        .map(|parts| {
1416            let mut value = String::new();
1417            let mut mapped = Vec::new();
1418            for part in parts {
1419                if raw {
1420                    append_mapped_text(
1421                        node_text(part, source),
1422                        part.start_byte(),
1423                        part.end_byte(),
1424                        &mut value,
1425                        &mut mapped,
1426                    )?;
1427                } else if part.kind() == "escape_sequence" {
1428                    decode_literal_escape(
1429                        node_text(part, source),
1430                        part.start_byte(),
1431                        &mut value,
1432                        &mut mapped,
1433                        false,
1434                    )?;
1435                } else {
1436                    decode_string_content(part, source, &mut value, &mut mapped, false)?;
1437                }
1438            }
1439            Ok(value.replace("{{", "{").replace("}}", "}"))
1440        })
1441        .collect()
1442}
1443
1444fn decode_string_segment(
1445    node: Node<'_>,
1446    source: &str,
1447    value: &mut String,
1448    output: &mut Vec<MappedChar>,
1449) -> Result<bool, &'static str> {
1450    let mut start_token = None;
1451    let mut body = Vec::new();
1452    let mut cursor = node.walk();
1453    for child in node.named_children(&mut cursor) {
1454        match child.kind() {
1455            "string_start" => start_token = Some(child),
1456            "string_end" => {}
1457            "string_content" | "escape_sequence" => body.push(child),
1458            "interpolation" => return Err("formatted-string-interpolation"),
1459            _ => return Err("unsupported-string-segment"),
1460        }
1461    }
1462    let start_token = start_token.ok_or("missing-string-start")?;
1463    let prefix = string_prefix(node_text(start_token, source));
1464    if prefix.contains('f') || prefix.contains('t') {
1465        return Err("non-text-or-formatted-string");
1466    }
1467    if prefix
1468        .chars()
1469        .any(|prefix| !matches!(prefix, 'r' | 'u' | 'b'))
1470        || prefix.matches('r').count() > 1
1471        || prefix.matches('u').count() > 1
1472        || prefix.matches('b').count() > 1
1473        || (prefix.contains('r') && prefix.contains('u'))
1474        || (prefix.contains('b') && prefix.contains('u'))
1475    {
1476        return Err("unsupported-string-prefix");
1477    }
1478    let is_bytes = prefix.contains('b');
1479    if is_bytes && !node_text(node, source).is_ascii() {
1480        return Err("non-ascii-byte-literal");
1481    }
1482    let raw = prefix.contains('r');
1483    for part in body {
1484        let raw_text = node_text(part, source);
1485        if raw && matches!(part.kind(), "escape_sequence" | "string_content") {
1486            append_mapped_text(raw_text, part.start_byte(), part.end_byte(), value, output)?;
1487        } else if !raw && part.kind() == "escape_sequence" {
1488            decode_literal_escape(raw_text, part.start_byte(), value, output, is_bytes)?;
1489        } else if !raw && part.kind() == "string_content" {
1490            decode_string_content(part, source, value, output, is_bytes)?;
1491        } else {
1492            append_mapped_text(raw_text, part.start_byte(), part.end_byte(), value, output)?;
1493        }
1494        if output.len() > MAX_PATTERN_CHARS {
1495            return Err("pattern-character-budget");
1496        }
1497    }
1498    if is_bytes && value.chars().any(|character| character as u32 > 0xff) {
1499        return Err("byte-pattern-value-out-of-range");
1500    }
1501    Ok(is_bytes)
1502}
1503
1504fn string_prefix(start: &str) -> String {
1505    start
1506        .chars()
1507        .take_while(|character| !matches!(character, '\'' | '"'))
1508        .map(|character| character.to_ascii_lowercase())
1509        .collect()
1510}
1511
1512fn append_mapped_text(
1513    text: &str,
1514    source_start: usize,
1515    source_end: usize,
1516    output_text: &mut String,
1517    output: &mut Vec<MappedChar>,
1518) -> Result<(), &'static str> {
1519    for (offset, value) in text.char_indices() {
1520        if output.len() >= MAX_PATTERN_CHARS {
1521            return Err("pattern-character-budget");
1522        }
1523        let start = source_start + offset;
1524        let end = start + value.len_utf8();
1525        output_text.push(value);
1526        output.push(MappedChar {
1527            source_start: start,
1528            source_end: end,
1529        });
1530    }
1531    debug_assert_eq!(source_start + text.len(), source_end);
1532    Ok(())
1533}
1534
1535fn decode_string_content(
1536    node: Node<'_>,
1537    source: &str,
1538    output_text: &mut String,
1539    output: &mut Vec<MappedChar>,
1540    bytes: bool,
1541) -> Result<(), &'static str> {
1542    let mut position = node.start_byte();
1543    let mut cursor = node.walk();
1544    for child in node.named_children(&mut cursor) {
1545        assert_eq!(child.kind(), "escape_sequence");
1546        assert!(position <= child.start_byte());
1547        if position < child.start_byte() {
1548            let plain = source
1549                .get(position..child.start_byte())
1550                .expect("tree-sitter string ranges are valid UTF-8 source");
1551            append_mapped_text(plain, position, child.start_byte(), output_text, output)?;
1552        }
1553        decode_literal_escape(
1554            node_text(child, source),
1555            child.start_byte(),
1556            output_text,
1557            output,
1558            bytes,
1559        )?;
1560        position = child.end_byte();
1561    }
1562    assert!(position <= node.end_byte());
1563    if position < node.end_byte() {
1564        let plain = source
1565            .get(position..node.end_byte())
1566            .expect("tree-sitter string ranges are valid UTF-8 source");
1567        append_mapped_text(plain, position, node.end_byte(), output_text, output)?;
1568    }
1569    Ok(())
1570}
1571
1572fn decode_literal_escape(
1573    raw: &str,
1574    source_start: usize,
1575    output_text: &mut String,
1576    output: &mut Vec<MappedChar>,
1577    bytes: bool,
1578) -> Result<(), &'static str> {
1579    let chars: Vec<char> = raw.chars().collect();
1580    if chars.first() != Some(&'\\') || chars.len() < 2 {
1581        return Err("malformed-literal-escape");
1582    }
1583    if bytes && matches!(chars.get(1), Some('u' | 'U' | 'N')) {
1584        return append_mapped_text(
1585            raw,
1586            source_start,
1587            source_start + raw.len(),
1588            output_text,
1589            output,
1590        );
1591    }
1592    let escaped = chars[1];
1593    let replacement = match escaped {
1594        '\n' => None,
1595        '\r' if chars.get(2) == Some(&'\n') => None,
1596        '\r' => Some('\r'),
1597        '\\' => Some('\\'),
1598        '\'' => Some('\''),
1599        '"' => Some('"'),
1600        'a' => Some('\u{7}'),
1601        'b' => Some('\u{8}'),
1602        'f' => Some('\u{c}'),
1603        'n' => Some('\n'),
1604        'r' => Some('\r'),
1605        't' => Some('\t'),
1606        'v' => Some('\u{b}'),
1607        'x' => Some(
1608            char::from_u32(hex_value(&chars, 2, 2).ok_or("invalid-hex-escape")?)
1609                .ok_or("invalid-hex-scalar")?,
1610        ),
1611        'u' => Some(
1612            char::from_u32(hex_value(&chars, 2, 4).ok_or("invalid-unicode-escape")?)
1613                .ok_or("invalid-unicode-scalar")?,
1614        ),
1615        'U' => Some(
1616            char::from_u32(hex_value(&chars, 2, 8).ok_or("invalid-unicode-escape")?)
1617                .ok_or("invalid-unicode-scalar")?,
1618        ),
1619        'N' => return Err("named-unicode-escape"),
1620        '0'..='7' => {
1621            let mut value = (escaped as u32) - ('0' as u32);
1622            let mut digits = 1usize;
1623            while digits < 3 {
1624                let Some(digit @ '0'..='7') = chars.get(1 + digits).copied() else {
1625                    break;
1626                };
1627                value = value * 8 + (digit as u32 - '0' as u32);
1628                digits += 1;
1629            }
1630            Some(char::from_u32(value).ok_or("invalid-octal-scalar")?)
1631        }
1632        // Python preserves unrecognized escapes, including punctuation; the
1633        // regex parser must receive the backslash to classify them correctly.
1634        other => {
1635            if output.len().saturating_add(2) > MAX_PATTERN_CHARS {
1636                return Err("pattern-character-budget");
1637            }
1638            output_text.push('\\');
1639            output.push(MappedChar {
1640                source_start,
1641                source_end: source_start + 1,
1642            });
1643            output_text.push(other);
1644            output.push(MappedChar {
1645                source_start: source_start + 1,
1646                source_end: source_start + 1 + other.len_utf8(),
1647            });
1648            return Ok(());
1649        }
1650    };
1651    if let Some(replacement) = replacement {
1652        if output.len() >= MAX_PATTERN_CHARS {
1653            return Err("pattern-character-budget");
1654        }
1655        output_text.push(replacement);
1656        output.push(MappedChar {
1657            source_start,
1658            source_end: source_start + raw.len(),
1659        });
1660    }
1661    Ok(())
1662}
1663
1664fn hex_value(chars: &[char], start: usize, digits: usize) -> Option<u32> {
1665    chars
1666        .get(start..start + digits)?
1667        .iter()
1668        .try_fold(0u32, |value, digit| {
1669            digit.to_digit(16).map(|digit| value * 16 + digit)
1670        })
1671}
1672
1673struct RegexParser<'a> {
1674    decoded: &'a DecodedValue,
1675    flags: RegexFlags,
1676    api: Api,
1677    chars: Vec<char>,
1678    index: usize,
1679    facts: Vec<Fact>,
1680    frames: Vec<BranchFrame>,
1681    pending_risks: Vec<(NormalizedKind, usize, usize, usize, String)>,
1682    verbose_comments: Vec<(usize, usize)>,
1683    incomplete: Option<(usize, usize, String)>,
1684    exceeded: bool,
1685}
1686
1687impl<'a> RegexParser<'a> {
1688    fn new(decoded: &'a DecodedValue, flags: RegexFlags, api: Api) -> Self {
1689        Self {
1690            decoded,
1691            flags,
1692            api,
1693            chars: decoded.value.chars().collect(),
1694            index: 0,
1695            facts: Vec::new(),
1696            frames: Vec::new(),
1697            pending_risks: Vec::new(),
1698            verbose_comments: Vec::new(),
1699            incomplete: None,
1700            exceeded: false,
1701        }
1702    }
1703
1704    fn parse(mut self) -> Vec<Fact> {
1705        let length = self.chars.len();
1706        if !self.flags.complete {
1707            self.mark_incomplete(0, length, "unsupported-regex-flags");
1708        }
1709        let pattern = self.add_fact(
1710            NormalizedKind::RegexPattern,
1711            0,
1712            length,
1713            None,
1714            Some(self.decoded.value.clone()),
1715            None,
1716        );
1717        let sequence = self.add_fact(
1718            NormalizedKind::RegexSequence,
1719            0,
1720            length,
1721            Some(pattern),
1722            None,
1723            None,
1724        );
1725        self.frames.push(BranchFrame {
1726            owner: pattern,
1727            sequence,
1728            group_start: None,
1729            last_atom: None,
1730            restore_flags: None,
1731        });
1732
1733        while self.index < length {
1734            if self.exceeded {
1735                break;
1736            }
1737            if self.flags.verbose {
1738                if matches!(
1739                    self.chars[self.index],
1740                    ' ' | '\t' | '\n' | '\r' | '\x0c' | '\x0b'
1741                ) {
1742                    self.index += 1;
1743                    continue;
1744                }
1745                if self.chars[self.index] == '#' {
1746                    let start = self.index;
1747                    while self.index < length && self.chars[self.index] != '\n' {
1748                        self.index += 1;
1749                    }
1750                    self.verbose_comments.push((start, self.index));
1751                    continue;
1752                }
1753            }
1754            if self.facts.len() >= MAX_PATTERN_FACTS - 2 {
1755                self.exceeded = true;
1756                break;
1757            }
1758            match self.chars[self.index] {
1759                '(' => self.open_group(),
1760                ')' => self.close_group(),
1761                '|' => self.alternation(),
1762                '[' => self.character_class(),
1763                '\\' => self.escape(false),
1764                '.' => self.wildcard(),
1765                '^' | '$' => self.anchor(),
1766                '*' | '+' | '?' => self.quantifier(),
1767                '{' if self.braced_quantifier_end().is_some() => self.quantifier(),
1768                _ => self.literal(),
1769            }
1770        }
1771
1772        if self.frames.len() > 1 {
1773            let start = self
1774                .frames
1775                .last()
1776                .and_then(|frame| frame.group_start)
1777                .unwrap_or(length);
1778            self.mark_incomplete(start, length, "unclosed-group");
1779            for frame in self.frames.iter().skip(1) {
1780                self.facts[frame.owner].end = length;
1781                self.facts[frame.sequence].end = length;
1782            }
1783        }
1784        if self.exceeded {
1785            return self.budget_result();
1786        }
1787        self.compute_minimum_widths();
1788        self.classify_verbose_comment_duplicates();
1789        self.classify_unmatchable_anchors();
1790        self.classify_hostname();
1791        self.classify_bad_tag();
1792        self.classify_polynomial_redos();
1793        self.classify_redos();
1794        self.append_pending_facts();
1795        if self.exceeded {
1796            return self.budget_result();
1797        }
1798        self.facts
1799    }
1800
1801    fn classify_verbose_comment_duplicates(&mut self) {
1802        if self.verbose_comments.is_empty()
1803            || !self
1804                .facts
1805                .iter()
1806                .any(|fact| fact.kind == NormalizedKind::RegexCharacterClass)
1807        {
1808            return;
1809        }
1810        let mut positions = HashMap::<char, Vec<usize>>::new();
1811        for &(start, end) in &self.verbose_comments {
1812            for (offset, character) in self.chars[start..end].iter().copied().enumerate() {
1813                if !character.is_whitespace() {
1814                    positions.entry(character).or_default().push(start + offset);
1815                }
1816            }
1817        }
1818        let mut duplicates = positions
1819            .into_iter()
1820            .filter_map(|(character, offsets)| {
1821                (offsets.len() > 1).then(|| (character, offsets[0], offsets[1]))
1822            })
1823            .collect::<Vec<_>>();
1824        duplicates.sort_unstable_by_key(|(character, _, _)| *character);
1825        if let Some((character, _, _)) = duplicates.first() {
1826            self.pending_risks.push((
1827                NormalizedKind::RegexRiskDuplicateClassCharacter,
1828                0,
1829                0,
1830                self.chars.len(),
1831                format!("duplicate-verbose-comment-character:{character}"),
1832            ));
1833        }
1834    }
1835
1836    fn budget_result(&self) -> Vec<Fact> {
1837        let length = self.chars.len();
1838        vec![
1839            self.facts[0].clone(),
1840            self.facts[1].clone(),
1841            Fact {
1842                kind: NormalizedKind::RegexIncomplete,
1843                start: 0,
1844                end: length,
1845                parent: Some(0),
1846                construct: Some("regex-fact-budget".to_owned()),
1847                first_set: None,
1848                escaped_literal: None,
1849                quantifier: None,
1850                minimum_width: None,
1851            },
1852        ]
1853    }
1854
1855    fn add_fact(
1856        &mut self,
1857        kind: NormalizedKind,
1858        start: usize,
1859        end: usize,
1860        parent: Option<usize>,
1861        construct: Option<String>,
1862        first_set: Option<CharacterSet>,
1863    ) -> usize {
1864        let id = self.facts.len();
1865        assert!(
1866            id < MAX_PATTERN_FACTS,
1867            "regex fact budget checked before insertion"
1868        );
1869        let minimum_width = match kind {
1870            NormalizedKind::RegexLiteral
1871            | NormalizedKind::RegexWildcard
1872            | NormalizedKind::RegexCharacterClass
1873            | NormalizedKind::RegexCharacterRange
1874            | NormalizedKind::RegexEscape => Some(1),
1875            NormalizedKind::RegexAnchor => Some(0),
1876            _ => None,
1877        };
1878        self.facts.push(Fact {
1879            kind,
1880            start,
1881            end,
1882            parent,
1883            construct,
1884            first_set,
1885            escaped_literal: None,
1886            quantifier: None,
1887            minimum_width,
1888        });
1889        id
1890    }
1891
1892    fn mark_incomplete(&mut self, start: usize, end: usize, reason: &str) {
1893        if self.incomplete.is_none() {
1894            self.incomplete = Some((start, end, reason.to_owned()));
1895        }
1896    }
1897
1898    fn current_sequence(&self) -> usize {
1899        self.frames
1900            .last()
1901            .expect("regex parser always has an outer frame")
1902            .sequence
1903    }
1904
1905    fn record_atom(&mut self, fact: usize) {
1906        self.frames
1907            .last_mut()
1908            .expect("regex parser always has an outer frame")
1909            .last_atom = Some(fact);
1910    }
1911
1912    fn add_atom(
1913        &mut self,
1914        kind: NormalizedKind,
1915        start: usize,
1916        end: usize,
1917        construct: Option<String>,
1918        first_set: Option<CharacterSet>,
1919    ) -> usize {
1920        let fact = self.add_fact(
1921            kind,
1922            start,
1923            end,
1924            Some(self.current_sequence()),
1925            construct,
1926            first_set,
1927        );
1928        self.record_atom(fact);
1929        fact
1930    }
1931
1932    fn open_group(&mut self) {
1933        let start = self.index;
1934        let mut content_start = start + 1;
1935        let mut group_type = "capturing";
1936        let mut missing_special_group_end = None;
1937        let mut restore_flags = None;
1938        if self.chars.get(content_start) == Some(&'?') {
1939            match (
1940                self.chars.get(content_start + 1),
1941                self.chars.get(content_start + 2),
1942            ) {
1943                (Some(':'), _) => {
1944                    group_type = "noncapturing";
1945                    content_start += 2;
1946                }
1947                (Some('P'), Some('<')) => {
1948                    group_type = "named-capturing";
1949                    let mut close = content_start + 3;
1950                    while close < self.chars.len() && self.chars[close] != '>' {
1951                        close += 1;
1952                    }
1953                    if close == self.chars.len() {
1954                        self.mark_incomplete(start, self.chars.len(), "unclosed-group-name");
1955                        self.index = self.chars.len();
1956                        return;
1957                    }
1958                    content_start = close + 1;
1959                }
1960                (Some('#'), _) => {
1961                    let mut close = content_start + 2;
1962                    while close < self.chars.len() && self.chars[close] != ')' {
1963                        close += 1;
1964                    }
1965                    if close == self.chars.len() {
1966                        self.mark_incomplete(start, close, "unclosed-comment-group");
1967                        self.index = close;
1968                        return;
1969                    }
1970                    let group = self.add_fact(
1971                        NormalizedKind::RegexGroup,
1972                        start,
1973                        close + 1,
1974                        Some(self.current_sequence()),
1975                        Some("comment".to_owned()),
1976                        None,
1977                    );
1978                    self.facts[group].minimum_width = Some(0);
1979                    self.index = close + 1;
1980                    self.record_atom(group);
1981                    return;
1982                }
1983                (Some('='), _) => {
1984                    group_type = "lookahead-positive";
1985                    content_start += 2;
1986                }
1987                (Some('!'), _) => {
1988                    group_type = "lookahead-negative";
1989                    content_start += 2;
1990                }
1991                (Some('<'), Some('=')) => {
1992                    group_type = "lookbehind-positive";
1993                    content_start += 3;
1994                }
1995                (Some('<'), Some('!')) => {
1996                    group_type = "lookbehind-negative";
1997                    content_start += 3;
1998                }
1999                (Some('i' | 'm' | 's' | 'x' | 'a' | 'u' | '-'), _) => {
2000                    let flags_start = content_start + 1;
2001                    let Some((delimiter, flags)) =
2002                        parse_inline_flags(&self.chars, flags_start, self.flags)
2003                    else {
2004                        self.mark_incomplete(
2005                            start,
2006                            (start + 3).min(self.chars.len()),
2007                            "invalid-inline-flags",
2008                        );
2009                        self.index = (start + 3).min(self.chars.len());
2010                        return;
2011                    };
2012                    match self.chars[delimiter] {
2013                        ')' => {
2014                            self.flags = flags;
2015                            self.index = delimiter + 1;
2016                            return;
2017                        }
2018                        ':' => {
2019                            group_type = "scoped-flags";
2020                            restore_flags = Some(self.flags);
2021                            self.flags = flags;
2022                            content_start = delimiter + 1;
2023                        }
2024                        _ => unreachable!("inline flags stop at a group delimiter"),
2025                    }
2026                }
2027                _ => {
2028                    self.mark_incomplete(
2029                        start,
2030                        (start + 3).min(self.chars.len()),
2031                        "unsupported-special-group",
2032                    );
2033                    content_start += 1;
2034                }
2035            }
2036        } else if self.chars.get(content_start) == Some(&'P')
2037            && self.chars.get(content_start + 1) == Some(&'<')
2038        {
2039            let mut close = content_start + 2;
2040            while close < self.chars.len() && self.chars[close] != '>' {
2041                close += 1;
2042            }
2043            let name = &self.chars[content_start + 2..close.min(self.chars.len())];
2044            let valid_name = name
2045                .first()
2046                .is_some_and(|first| first.is_alphabetic() || *first == '_')
2047                && name
2048                    .iter()
2049                    .all(|character| character.is_alphanumeric() || *character == '_');
2050            if close < self.chars.len() && valid_name {
2051                missing_special_group_end = Some(close + 1);
2052            }
2053        }
2054        if self.frames.len() >= MAX_GROUP_DEPTH {
2055            self.mark_incomplete(
2056                start,
2057                (start + 1).min(self.chars.len()),
2058                "regex-group-depth-budget",
2059            );
2060            self.exceeded = true;
2061            return;
2062        }
2063        let owner = self.current_sequence();
2064        let group = self.add_fact(
2065            NormalizedKind::RegexGroup,
2066            start,
2067            self.chars.len(),
2068            Some(owner),
2069            Some(group_type.to_owned()),
2070            None,
2071        );
2072        if let Some(end) = missing_special_group_end {
2073            self.pending_risks.push((
2074                NormalizedKind::RegexRiskIncompleteSpecialGroup,
2075                group,
2076                start,
2077                end,
2078                "named-group-missing-question-mark".to_owned(),
2079            ));
2080        }
2081        let sequence = self.add_fact(
2082            NormalizedKind::RegexSequence,
2083            content_start,
2084            self.chars.len(),
2085            Some(group),
2086            None,
2087            None,
2088        );
2089        self.frames.push(BranchFrame {
2090            owner: group,
2091            sequence,
2092            group_start: Some(start),
2093            last_atom: None,
2094            restore_flags,
2095        });
2096        self.index = content_start;
2097    }
2098
2099    fn close_group(&mut self) {
2100        if self.frames.len() == 1 {
2101            self.mark_incomplete(self.index, self.index + 1, "unmatched-close-parenthesis");
2102            self.index += 1;
2103            return;
2104        }
2105        let close = self.index;
2106        self.index += 1;
2107        let frame = self
2108            .frames
2109            .pop()
2110            .expect("a nested regex group frame exists");
2111        self.facts[frame.sequence].end = close;
2112        self.facts[frame.owner].end = self.index;
2113        self.facts[frame.owner].first_set = self.sequence_first_set(frame.sequence);
2114        self.facts[frame.sequence].minimum_width = self.sequence_minimum_width(frame.sequence);
2115        self.facts[frame.owner].minimum_width = if matches!(
2116            self.facts[frame.owner].construct.as_deref(),
2117            Some(
2118                "lookahead-positive"
2119                    | "lookahead-negative"
2120                    | "lookbehind-positive"
2121                    | "lookbehind-negative"
2122            )
2123        ) {
2124            Some(0)
2125        } else {
2126            self.facts[frame.sequence].minimum_width
2127        };
2128        if self.facts[frame.owner]
2129            .construct
2130            .as_deref()
2131            .is_some_and(|construct| construct.starts_with("look"))
2132        {
2133            self.facts[frame.owner].first_set = None;
2134        }
2135        if let Some(flags) = frame.restore_flags {
2136            self.flags = flags;
2137        }
2138        self.record_atom(frame.owner);
2139    }
2140
2141    fn alternation(&mut self) {
2142        let start = self.index;
2143        self.index += 1;
2144        let sequence = self.current_sequence();
2145        self.add_fact(
2146            NormalizedKind::RegexAlternation,
2147            start,
2148            self.index,
2149            Some(sequence),
2150            None,
2151            None,
2152        );
2153        let frame = self.frames.last_mut().expect("outer regex frame exists");
2154        frame.last_atom = None;
2155        // A dollar assertion at the end of a branch does not constrain the
2156        // next branch. Anchors are classified after the complete tree exists.
2157        // (The branch boundary itself is represented by the alternation fact.)
2158    }
2159
2160    fn literal(&mut self) {
2161        let start = self.index;
2162        let value = self.chars[self.index];
2163        self.index += 1;
2164        let set = CharacterSet::one(value);
2165        let set = if self.flags.ignorecase {
2166            set.with_ascii_case_variants()
2167        } else {
2168            set
2169        };
2170        self.add_atom(
2171            NormalizedKind::RegexLiteral,
2172            start,
2173            self.index,
2174            Some(value.to_string()),
2175            Some(set),
2176        );
2177    }
2178
2179    fn wildcard(&mut self) {
2180        let start = self.index;
2181        self.index += 1;
2182        let set = if self.flags.dotall {
2183            CharacterSet {
2184                ranges: vec![(0, 0x10ffff)],
2185            }
2186        } else {
2187            CharacterSet::any_except_newline()
2188        };
2189        self.add_atom(
2190            NormalizedKind::RegexWildcard,
2191            start,
2192            self.index,
2193            Some("dot".to_owned()),
2194            Some(set),
2195        );
2196    }
2197
2198    fn anchor(&mut self) {
2199        let start = self.index;
2200        let value = self.chars[self.index];
2201        self.index += 1;
2202        self.add_fact(
2203            NormalizedKind::RegexAnchor,
2204            start,
2205            self.index,
2206            Some(self.current_sequence()),
2207            Some(value.to_string()),
2208            None,
2209        );
2210        self.frames
2211            .last_mut()
2212            .expect("outer regex frame exists")
2213            .last_atom = None;
2214    }
2215
2216    fn quantifier(&mut self) {
2217        let start = self.index;
2218        let Some(atom) = self.frames.last().and_then(|frame| frame.last_atom) else {
2219            self.mark_incomplete(
2220                start,
2221                (start + 1).min(self.chars.len()),
2222                "quantifier-without-atom",
2223            );
2224            self.index += 1;
2225            return;
2226        };
2227        if self.facts[atom].quantifier.is_some() {
2228            self.mark_incomplete(
2229                start,
2230                (start + 1).min(self.chars.len()),
2231                "nested-quantifier",
2232            );
2233            self.index += 1;
2234            return;
2235        }
2236        let mut quantifier = match self.chars[self.index] {
2237            '*' => {
2238                self.index += 1;
2239                Quantifier {
2240                    minimum: 0,
2241                    maximum: None,
2242                    possessive: false,
2243                }
2244            }
2245            '+' => {
2246                self.index += 1;
2247                Quantifier {
2248                    minimum: 1,
2249                    maximum: None,
2250                    possessive: false,
2251                }
2252            }
2253            '?' => {
2254                self.index += 1;
2255                Quantifier {
2256                    minimum: 0,
2257                    maximum: Some(1),
2258                    possessive: false,
2259                }
2260            }
2261            '{' => match self.braced_quantifier_end() {
2262                Some((end, quantifier)) => {
2263                    self.index = end;
2264                    quantifier
2265                }
2266                None => {
2267                    self.mark_incomplete(
2268                        start,
2269                        (start + 1).min(self.chars.len()),
2270                        "malformed-braced-quantifier",
2271                    );
2272                    self.index += 1;
2273                    return;
2274                }
2275            },
2276            _ => unreachable!("quantifier dispatch uses a quantifier character"),
2277        };
2278        if matches!(self.chars.get(self.index), Some('?') | Some('+')) {
2279            quantifier.possessive = self.chars[self.index] == '+';
2280            self.index += 1;
2281        }
2282        let end = self.index;
2283        let fact = self.add_fact(
2284            NormalizedKind::RegexQuantifier,
2285            start,
2286            end,
2287            Some(atom),
2288            Some(format!(
2289                "min={},max={},possessive={}",
2290                quantifier.minimum,
2291                quantifier
2292                    .maximum
2293                    .map_or_else(|| "*".to_owned(), |maximum| maximum.to_string()),
2294                quantifier.possessive
2295            )),
2296            None,
2297        );
2298        self.facts[fact].quantifier = Some(quantifier);
2299        self.facts[atom].minimum_width = self.facts[atom]
2300            .minimum_width
2301            .and_then(|width| width.checked_mul(quantifier.minimum));
2302        self.facts[atom].end = end;
2303    }
2304
2305    fn braced_quantifier_end(&self) -> Option<(usize, Quantifier)> {
2306        let mut index = self.index + 1;
2307        let number_start = index;
2308        let mut minimum = 0u32;
2309        while let Some(digit @ '0'..='9') = self.chars.get(index).copied() {
2310            minimum = minimum
2311                .checked_mul(10)?
2312                .checked_add(digit as u32 - '0' as u32)?;
2313            index += 1;
2314        }
2315        if index == number_start {
2316            return None;
2317        }
2318        let mut maximum = Some(minimum);
2319        if self.chars.get(index) == Some(&',') {
2320            index += 1;
2321            let upper_start = index;
2322            let mut upper = 0u32;
2323            while let Some(digit @ '0'..='9') = self.chars.get(index).copied() {
2324                upper = upper
2325                    .checked_mul(10)?
2326                    .checked_add(digit as u32 - '0' as u32)?;
2327                index += 1;
2328            }
2329            maximum = if upper_start == index {
2330                None
2331            } else {
2332                Some(upper)
2333            };
2334        }
2335        if self.chars.get(index) != Some(&'}') || maximum.is_some_and(|maximum| maximum < minimum) {
2336            return None;
2337        }
2338        Some((
2339            index + 1,
2340            Quantifier {
2341                minimum,
2342                maximum,
2343                possessive: false,
2344            },
2345        ))
2346    }
2347
2348    fn escape(&mut self, in_class: bool) {
2349        let start = self.index;
2350        let Some(escape) = parse_regex_escape(&self.chars, start, in_class, self.flags.ascii)
2351        else {
2352            self.mark_incomplete(
2353                start,
2354                (start + 2).min(self.chars.len()),
2355                "unsupported-escape",
2356            );
2357            self.index = (start + 2).min(self.chars.len());
2358            return;
2359        };
2360        self.index = escape.end;
2361        if let Some(reason) = escape.incomplete {
2362            self.mark_incomplete(start, self.index, reason);
2363        }
2364        let kind = if escape.anchor && !in_class {
2365            NormalizedKind::RegexAnchor
2366        } else {
2367            NormalizedKind::RegexEscape
2368        };
2369        let escaped_literal = escape.first_set.as_ref().and_then(single_character);
2370        let first_set = escape.first_set.map(|set| {
2371            if self.flags.ignorecase {
2372                set.with_ascii_case_variants()
2373            } else {
2374                set
2375            }
2376        });
2377        let fact = self.add_fact(
2378            kind,
2379            start,
2380            self.index,
2381            Some(self.current_sequence()),
2382            escape.construct,
2383            first_set,
2384        );
2385        self.facts[fact].escaped_literal = escaped_literal;
2386        if escape.anchor && !in_class {
2387            self.frames
2388                .last_mut()
2389                .expect("outer regex frame exists")
2390                .last_atom = None;
2391        } else {
2392            self.record_atom(fact);
2393        }
2394        if escape.backspace && in_class {
2395            self.pending_risks.push((
2396                NormalizedKind::RegexRiskBackspaceEscape,
2397                fact,
2398                start,
2399                self.index,
2400                "backspace-in-character-class".to_owned(),
2401            ));
2402        }
2403    }
2404
2405    fn character_class(&mut self) {
2406        let start = self.index;
2407        self.index += 1;
2408        let class = self.add_fact(
2409            NormalizedKind::RegexCharacterClass,
2410            start,
2411            self.chars.len(),
2412            Some(self.current_sequence()),
2413            None,
2414            None,
2415        );
2416        let negated = self.chars.get(self.index) == Some(&'^');
2417        if negated {
2418            self.index += 1;
2419        }
2420        let mut members = CharacterSet { ranges: Vec::new() };
2421        let mut members_known = true;
2422        let mut saw_member = false;
2423        let mut closed = false;
2424        let mut explicit_members = HashSet::<String>::new();
2425        let mut range_members = Vec::<(usize, CharacterSet, u32)>::new();
2426        let mut category_members = Vec::new();
2427        while self.index < self.chars.len() {
2428            if self.facts.len() >= MAX_PATTERN_FACTS - 2 {
2429                self.exceeded = true;
2430                break;
2431            }
2432            if self.chars[self.index] == ']' && saw_member {
2433                self.index += 1;
2434                closed = true;
2435                break;
2436            }
2437            let Some(left) = parse_class_atom(&self.chars, self.index, self.flags.ascii) else {
2438                self.mark_incomplete(
2439                    self.index,
2440                    (self.index + 1).min(self.chars.len()),
2441                    "unsupported-class-escape",
2442                );
2443                self.index += 1;
2444                members_known = false;
2445                continue;
2446            };
2447            self.index = left.end;
2448            if let Some(reason) = left.incomplete {
2449                self.mark_incomplete(left.start, left.end, reason);
2450                members_known = false;
2451            }
2452            let has_range = self.chars.get(self.index) == Some(&'-')
2453                && self
2454                    .chars
2455                    .get(self.index + 1)
2456                    .is_some_and(|next| *next != ']');
2457            if has_range {
2458                let dash = self.index;
2459                self.index += 1;
2460                let Some(right) = parse_class_atom(&self.chars, self.index, self.flags.ascii)
2461                else {
2462                    self.mark_incomplete(dash, dash + 1, "unsupported-class-range");
2463                    members_known = false;
2464                    continue;
2465                };
2466                self.index = right.end;
2467                if let Some(reason) = right.incomplete {
2468                    self.mark_incomplete(right.start, right.end, reason);
2469                    members_known = false;
2470                }
2471                let Some((left_value, right_value)) = left.value.zip(right.value) else {
2472                    self.mark_incomplete(left.start, right.end, "non-literal-class-range");
2473                    members_known = false;
2474                    continue;
2475                };
2476                if left_value > right_value {
2477                    let range_fact = self.add_fact(
2478                        NormalizedKind::RegexCharacterRange,
2479                        left.start,
2480                        right.end,
2481                        Some(class),
2482                        Some(format!(
2483                            "U+{:04X}-U+{:04X}",
2484                            left_value as u32, right_value as u32
2485                        )),
2486                        Some(CharacterSet { ranges: Vec::new() }),
2487                    );
2488                    self.pending_risks.push((
2489                        NormalizedKind::RegexRiskRange,
2490                        range_fact,
2491                        left.start,
2492                        right.end,
2493                        "empty-descending-range".to_owned(),
2494                    ));
2495                    self.mark_incomplete(left.start, right.end, "descending-class-range");
2496                    members_known = false;
2497                    saw_member = true;
2498                    continue;
2499                }
2500                let range_set = CharacterSet {
2501                    ranges: vec![(left_value as u32, right_value as u32)],
2502                };
2503                let range_set = if self.flags.ignorecase {
2504                    range_set.with_ascii_case_variants()
2505                } else {
2506                    range_set
2507                };
2508                let compatibility_set = codeql_range_character_set(left_value, right_value);
2509                let range_fact = self.add_fact(
2510                    NormalizedKind::RegexCharacterRange,
2511                    left.start,
2512                    right.end,
2513                    Some(class),
2514                    Some(format!(
2515                        "U+{:04X}-U+{:04X}",
2516                        left_value as u32, right_value as u32
2517                    )),
2518                    Some(range_set.clone()),
2519                );
2520                if let Some((previous_fact, _, previous_width)) = range_members
2521                    .iter()
2522                    .find(|(_, previous, _)| previous.intersects(&compatibility_set))
2523                {
2524                    let current_width = compatibility_set
2525                        .ranges
2526                        .iter()
2527                        .map(|(start, end)| end.saturating_sub(*start))
2528                        .max()
2529                        .unwrap_or_default();
2530                    let previous_start = self.facts[*previous_fact].start;
2531                    let selected_fact = if current_width > *previous_width
2532                        || (current_width == *previous_width && left.start < previous_start)
2533                    {
2534                        range_fact
2535                    } else {
2536                        *previous_fact
2537                    };
2538                    self.pending_risks.push((
2539                        NormalizedKind::RegexRiskRange,
2540                        selected_fact,
2541                        self.facts[selected_fact].start,
2542                        self.facts[selected_fact].end,
2543                        "overlapping-class-range".to_owned(),
2544                    ));
2545                }
2546                if category_members
2547                    .iter()
2548                    .any(|previous: &CharacterSet| previous.intersects(&compatibility_set))
2549                {
2550                    self.pending_risks.push((
2551                        NormalizedKind::RegexRiskRange,
2552                        range_fact,
2553                        left.start,
2554                        right.end,
2555                        "range-overlaps-character-class-escape".to_owned(),
2556                    ));
2557                }
2558                let compatibility_width = compatibility_set
2559                    .ranges
2560                    .iter()
2561                    .map(|(start, end)| end.saturating_sub(*start))
2562                    .max()
2563                    .unwrap_or_default();
2564                range_members.push((range_fact, compatibility_set, compatibility_width));
2565                members.union_with(&range_set);
2566                saw_member = true;
2567                if is_overly_broad_range(left_value, right_value) {
2568                    self.pending_risks.push((
2569                        NormalizedKind::RegexRiskRange,
2570                        range_fact,
2571                        left.start,
2572                        right.end,
2573                        "ascii-punctuation-between-letter-ranges".to_owned(),
2574                    ));
2575                }
2576            } else {
2577                let semantic_set = left.set.as_ref().map(|set| {
2578                    if self.flags.ignorecase {
2579                        set.clone().with_ascii_case_variants()
2580                    } else {
2581                        set.clone()
2582                    }
2583                });
2584                let is_category = left
2585                    .construct
2586                    .as_deref()
2587                    .is_some_and(|construct| construct.starts_with("category:"));
2588                let member_fact = self.add_fact(
2589                    left.kind,
2590                    left.start,
2591                    left.end,
2592                    Some(class),
2593                    left.construct,
2594                    semantic_set.clone(),
2595                );
2596                if let Some(set) = &semantic_set {
2597                    if left.value.is_some() {
2598                        let syntax = self.chars[left.start..left.end].iter().collect::<String>();
2599                        if !explicit_members.insert(syntax) {
2600                            self.pending_risks.push((
2601                                NormalizedKind::RegexRiskDuplicateClassCharacter,
2602                                member_fact,
2603                                left.start,
2604                                left.end,
2605                                "duplicate-class-member".to_owned(),
2606                            ));
2607                        }
2608                    }
2609                    if is_category {
2610                        if range_members
2611                            .iter()
2612                            .any(|(_, range, _)| range.intersects(set))
2613                        {
2614                            self.pending_risks.push((
2615                                NormalizedKind::RegexRiskRange,
2616                                member_fact,
2617                                left.start,
2618                                left.end,
2619                                "range-overlaps-character-class-escape".to_owned(),
2620                            ));
2621                        }
2622                        category_members.push(set.clone());
2623                    }
2624                    members.union_with(set);
2625                } else {
2626                    members_known = false;
2627                }
2628                saw_member = true;
2629                if left.backspace {
2630                    self.pending_risks.push((
2631                        NormalizedKind::RegexRiskBackspaceEscape,
2632                        member_fact,
2633                        left.start,
2634                        left.end,
2635                        "backspace-in-character-class".to_owned(),
2636                    ));
2637                }
2638            }
2639        }
2640        self.facts[class].end = self.index;
2641        if !closed {
2642            self.mark_incomplete(start, self.index, "unclosed-character-class");
2643        }
2644        if !saw_member {
2645            self.mark_incomplete(start, self.index, "empty-character-class");
2646        }
2647        if negated && members_known {
2648            members = members.complement();
2649        }
2650        self.facts[class].first_set = members_known.then_some(members);
2651        self.record_atom(class);
2652    }
2653
2654    fn sequence_first_set(&self, sequence: usize) -> Option<CharacterSet> {
2655        let branches = self.branch_first_sets(sequence);
2656        if branches.iter().any(Option::is_none) {
2657            return None;
2658        }
2659        let mut result = CharacterSet { ranges: Vec::new() };
2660        for branch in branches.into_iter().flatten() {
2661            result.union_with(&branch);
2662        }
2663        Some(result)
2664    }
2665
2666    fn branch_first_sets(&self, sequence: usize) -> Vec<Option<CharacterSet>> {
2667        let mut branches = Vec::new();
2668        let mut current = None;
2669        let mut consumed = false;
2670        for fact in self
2671            .facts
2672            .iter()
2673            .filter(|fact| fact.parent == Some(sequence))
2674        {
2675            if fact.kind == NormalizedKind::RegexAlternation {
2676                branches.push(current.take());
2677                consumed = false;
2678                continue;
2679            }
2680            if consumed || fact.kind == NormalizedKind::RegexAnchor {
2681                continue;
2682            }
2683            match fact.kind {
2684                NormalizedKind::RegexLiteral
2685                | NormalizedKind::RegexWildcard
2686                | NormalizedKind::RegexCharacterClass
2687                | NormalizedKind::RegexEscape
2688                | NormalizedKind::RegexGroup => {
2689                    current = fact.first_set.clone();
2690                    consumed = true;
2691                }
2692                _ => {}
2693            }
2694        }
2695        branches.push(current);
2696        branches
2697    }
2698
2699    fn compute_minimum_widths(&mut self) {
2700        for id in (0..self.facts.len()).rev() {
2701            match self.facts[id].kind {
2702                NormalizedKind::RegexSequence => {
2703                    self.facts[id].minimum_width = self.sequence_minimum_width(id);
2704                }
2705                NormalizedKind::RegexGroup => {
2706                    let sequence = self.facts.iter().position(|fact| {
2707                        fact.kind == NormalizedKind::RegexSequence && fact.parent == Some(id)
2708                    });
2709                    self.facts[id].minimum_width = if self.facts[id]
2710                        .construct
2711                        .as_deref()
2712                        .is_some_and(|construct| construct.starts_with("look"))
2713                    {
2714                        Some(0)
2715                    } else {
2716                        sequence.and_then(|sequence| self.facts[sequence].minimum_width)
2717                    };
2718                }
2719                _ => {}
2720            }
2721        }
2722    }
2723
2724    fn sequence_minimum_width(&self, sequence: usize) -> Option<u32> {
2725        let mut branches = Vec::new();
2726        let mut width = Some(0u32);
2727        for fact in self
2728            .facts
2729            .iter()
2730            .filter(|fact| fact.parent == Some(sequence))
2731        {
2732            if fact.kind == NormalizedKind::RegexAlternation {
2733                branches.push(width.take()?);
2734                width = Some(0);
2735                continue;
2736            }
2737            width = width?.checked_add(fact.minimum_width?);
2738        }
2739        branches.push(width?);
2740        branches.into_iter().min()
2741    }
2742
2743    fn classify_unmatchable_anchors(&mut self) {
2744        if self.incomplete.is_some() || self.flags.multiline {
2745            return;
2746        }
2747        let sequences = self
2748            .facts
2749            .iter()
2750            .enumerate()
2751            .filter(|(_, fact)| fact.kind == NormalizedKind::RegexSequence)
2752            .map(|(id, _)| id)
2753            .collect::<Vec<_>>();
2754        for sequence in sequences {
2755            let branches = regex_sequence_branches(
2756                &self.facts,
2757                self.facts
2758                    .iter()
2759                    .enumerate()
2760                    .filter(|(_, fact)| fact.parent == Some(sequence))
2761                    .map(|(id, _)| id),
2762            );
2763            for branch in branches {
2764                let mut prefix_width = Some(0u32);
2765                for (index, &id) in branch.iter().enumerate() {
2766                    let fact = &self.facts[id];
2767                    if fact.kind == NormalizedKind::RegexAnchor
2768                        && fact.construct.as_deref() == Some("^")
2769                        && (prefix_width.is_some_and(|width| width > 0)
2770                            || branch[..index].iter().any(|&prefix| {
2771                                self.facts[prefix].construct.as_deref()
2772                                    == Some("lookbehind-positive")
2773                                    && self.facts.iter().any(|body| {
2774                                        body.kind == NormalizedKind::RegexSequence
2775                                            && body.parent == Some(prefix)
2776                                            && body.minimum_width.is_some_and(|width| width > 0)
2777                                    })
2778                            }))
2779                    {
2780                        self.pending_risks.push((
2781                            NormalizedKind::RegexRiskUnmatchableCaret,
2782                            id,
2783                            fact.start,
2784                            fact.end,
2785                            "caret-after-consuming-term".to_owned(),
2786                        ));
2787                    } else if fact.kind == NormalizedKind::RegexAnchor
2788                        && fact.construct.as_deref() == Some("$")
2789                    {
2790                        let mut suffix_width = Some(0u32);
2791                        for &suffix_id in &branch[index + 1..] {
2792                            suffix_width = suffix_width.and_then(|width| {
2793                                self.facts[suffix_id]
2794                                    .minimum_width
2795                                    .and_then(|suffix| width.checked_add(suffix))
2796                            });
2797                        }
2798                        if suffix_width.is_some_and(|width| width > 0)
2799                            || branch[index + 1..].iter().any(|&suffix| {
2800                                self.facts[suffix].construct.as_deref()
2801                                    == Some("lookahead-positive")
2802                                    && self.facts.iter().any(|body| {
2803                                        body.kind == NormalizedKind::RegexSequence
2804                                            && body.parent == Some(suffix)
2805                                            && body.minimum_width.is_some_and(|width| width > 0)
2806                                    })
2807                            })
2808                        {
2809                            self.pending_risks.push((
2810                                NormalizedKind::RegexRiskUnmatchableDollar,
2811                                id,
2812                                fact.start,
2813                                fact.end,
2814                                "dollar-before-consuming-term".to_owned(),
2815                            ));
2816                        }
2817                    }
2818                    prefix_width =
2819                        prefix_width.and_then(|width| width.checked_add(fact.minimum_width?));
2820                }
2821            }
2822        }
2823    }
2824
2825    fn classify_hostname(&mut self) {
2826        if self.incomplete.is_some() {
2827            return;
2828        }
2829
2830        // Recognize hostname-LIKE values, not the intent of the regex's caller.
2831        // A wildcard is projected to a separator only while recognizing this
2832        // shape. Escaped dots and singleton dot classes consume the same shape
2833        // character but are not risks. The accepted path must contain the dot;
2834        // a suffix in another alternative cannot supply its hostname context.
2835        #[derive(Clone, Copy)]
2836        enum Token {
2837            Character(char),
2838            WildcardDot(usize),
2839            StartAnchor,
2840            EndAnchor,
2841        }
2842        #[derive(Clone, Copy, PartialEq, Eq, Hash)]
2843        enum LabelEnd {
2844            AlphaOne,
2845            AlphaMany,
2846            Alphanumeric,
2847            Hyphen,
2848        }
2849        #[derive(Clone, Copy, PartialEq, Eq, Hash)]
2850        enum Shape {
2851            Start,
2852            Prefix(&'static str),
2853            FirstLabel,
2854            NextLabel,
2855            Label(bool, LabelEnd),
2856            Slash,
2857            End,
2858        }
2859        impl Shape {
2860            fn accepts(self) -> bool {
2861                matches!(
2862                    self,
2863                    Self::Label(true, LabelEnd::AlphaMany) | Self::Slash | Self::End
2864                )
2865            }
2866
2867            fn advance(self, token: Token) -> Vec<Self> {
2868                let character = match token {
2869                    Token::StartAnchor => {
2870                        return if self == Self::Start {
2871                            vec![self]
2872                        } else {
2873                            Vec::new()
2874                        };
2875                    }
2876                    Token::EndAnchor => {
2877                        return if self.accepts() {
2878                            vec![Self::End]
2879                        } else {
2880                            Vec::new()
2881                        };
2882                    }
2883                    Token::Character(character) => character,
2884                    Token::WildcardDot(_) => '.',
2885                };
2886                let first = if character.is_ascii_alphabetic() {
2887                    Some(LabelEnd::AlphaOne)
2888                } else if character.is_ascii_digit() {
2889                    Some(LabelEnd::Alphanumeric)
2890                } else {
2891                    None
2892                };
2893                match self {
2894                    Self::Start => {
2895                        let mut next = first
2896                            .map(|end| Self::Label(false, end))
2897                            .into_iter()
2898                            .collect::<Vec<_>>();
2899                        if character == 'h' {
2900                            next.extend([Self::Prefix("ttp://"), Self::Prefix("ttps://")]);
2901                        }
2902                        next
2903                    }
2904                    Self::Prefix(rest) => {
2905                        if rest.starts_with(character) {
2906                            let rest = &rest[character.len_utf8()..];
2907                            vec![if rest.is_empty() {
2908                                Self::FirstLabel
2909                            } else {
2910                                Self::Prefix(rest)
2911                            }]
2912                        } else {
2913                            Vec::new()
2914                        }
2915                    }
2916                    Self::FirstLabel | Self::NextLabel => first
2917                        .map(|end| Self::Label(self == Self::NextLabel, end))
2918                        .into_iter()
2919                        .collect(),
2920                    Self::Label(separated, end) => {
2921                        let next = if character.is_ascii_alphabetic() {
2922                            Some(Self::Label(
2923                                separated,
2924                                match end {
2925                                    LabelEnd::AlphaOne | LabelEnd::AlphaMany => LabelEnd::AlphaMany,
2926                                    LabelEnd::Alphanumeric | LabelEnd::Hyphen => {
2927                                        LabelEnd::Alphanumeric
2928                                    }
2929                                },
2930                            ))
2931                        } else if character.is_ascii_digit() {
2932                            Some(Self::Label(separated, LabelEnd::Alphanumeric))
2933                        } else if character == '-' {
2934                            Some(Self::Label(separated, LabelEnd::Hyphen))
2935                        } else if character == '.' && end != LabelEnd::Hyphen {
2936                            Some(Self::NextLabel)
2937                        } else if character == '/' && self.accepts() {
2938                            Some(Self::Slash)
2939                        } else {
2940                            None
2941                        };
2942                        next.into_iter().collect()
2943                    }
2944                    Self::Slash | Self::End => Vec::new(),
2945                }
2946            }
2947        }
2948
2949        // Two boundaries per existing fact form an acyclic graph. Groups and
2950        // alternatives reuse their fact boundaries rather than expanding the
2951        // Cartesian product of label choices. Both the graph and its product
2952        // with the fixed-size Shape state set are bounded by MAX_PATTERN_FACTS.
2953        let mut children = vec![Vec::new(); self.facts.len()];
2954        for (id, fact) in self.facts.iter().enumerate() {
2955            if let Some(parent) = fact.parent {
2956                children[parent].push(id);
2957            }
2958        }
2959        let mut edges = vec![Vec::<(usize, Option<Token>)>::new(); self.facts.len() * 2];
2960        for (id, fact) in self.facts.iter().enumerate() {
2961            if children[id]
2962                .iter()
2963                .any(|&child| self.facts[child].kind == NormalizedKind::RegexQuantifier)
2964            {
2965                continue;
2966            }
2967            let start = id * 2;
2968            let end = start + 1;
2969            match fact.kind {
2970                NormalizedKind::RegexPattern | NormalizedKind::RegexGroup
2971                    if fact.kind == NormalizedKind::RegexPattern
2972                        || matches!(
2973                            fact.construct.as_deref(),
2974                            Some("capturing" | "named-capturing" | "noncapturing" | "scoped-flags")
2975                        ) =>
2976                {
2977                    for &sequence in &children[id] {
2978                        if self.facts[sequence].kind == NormalizedKind::RegexSequence {
2979                            edges[start].push((sequence * 2, None));
2980                            edges[sequence * 2 + 1].push((end, None));
2981                        }
2982                    }
2983                }
2984                NormalizedKind::RegexSequence => {
2985                    for branch in regex_sequence_branches(&self.facts, children[id].iter().copied())
2986                    {
2987                        let mut previous = start;
2988                        for child in branch {
2989                            edges[previous].push((child * 2, None));
2990                            previous = child * 2 + 1;
2991                        }
2992                        edges[previous].push((end, None));
2993                    }
2994                }
2995                _ => {
2996                    let token = match fact.kind {
2997                        NormalizedKind::RegexWildcard
2998                            if fact.construct.as_deref() == Some("dot") =>
2999                        {
3000                            Some(Token::WildcardDot(id))
3001                        }
3002                        NormalizedKind::RegexAnchor => match fact.construct.as_deref() {
3003                            Some("^" | "boundary:A") => Some(Token::StartAnchor),
3004                            Some("$" | "boundary:Z" | "boundary:z") => Some(Token::EndAnchor),
3005                            _ => None,
3006                        },
3007                        NormalizedKind::RegexLiteral => {
3008                            fact.construct.as_deref().and_then(|value| {
3009                                let mut chars = value.chars();
3010                                let first = chars.next()?;
3011                                chars.next().is_none().then_some(Token::Character(first))
3012                            })
3013                        }
3014                        NormalizedKind::RegexEscape => fact.escaped_literal.map(Token::Character),
3015                        NormalizedKind::RegexCharacterClass => fact
3016                            .first_set
3017                            .as_ref()
3018                            .and_then(single_character)
3019                            .map(Token::Character),
3020                        _ => None,
3021                    };
3022                    if let Some(token) = token {
3023                        edges[start].push((end, Some(token)));
3024                    }
3025                }
3026            }
3027        }
3028        let start = (0, Shape::Start);
3029        let mut reverse = HashMap::<_, Vec<(_, Option<usize>)>>::from([(start, Vec::new())]);
3030        let mut pending = VecDeque::from([start]);
3031        while let Some((boundary, shape)) = pending.pop_front() {
3032            for &(target, token) in &edges[boundary] {
3033                let next_shapes = token.map_or_else(|| vec![shape], |token| shape.advance(token));
3034                let risk = match token {
3035                    Some(Token::WildcardDot(id)) => Some(id),
3036                    _ => None,
3037                };
3038                for next_shape in next_shapes {
3039                    let next = (target, next_shape);
3040                    let newly_seen = !reverse.contains_key(&next);
3041                    reverse
3042                        .entry(next)
3043                        .or_default()
3044                        .push(((boundary, shape), risk));
3045                    if newly_seen {
3046                        pending.push_back(next);
3047                    }
3048                }
3049            }
3050        }
3051        let mut pending = reverse
3052            .keys()
3053            .copied()
3054            .filter(|&(boundary, shape)| boundary == 1 && shape.accepts())
3055            .collect::<Vec<_>>();
3056        let mut visited = HashSet::new();
3057        let mut risks = HashSet::new();
3058        while let Some(node) = pending.pop() {
3059            if !visited.insert(node) {
3060                continue;
3061            }
3062            for &(previous, risk) in &reverse[&node] {
3063                risks.extend(risk);
3064                pending.push(previous);
3065            }
3066        }
3067        let mut risks = risks.into_iter().collect::<Vec<_>>();
3068        risks.sort_unstable();
3069        for dot in risks {
3070            let fact = &self.facts[dot];
3071            self.pending_risks.push((
3072                NormalizedKind::RegexRiskHostname,
3073                dot,
3074                fact.start,
3075                fact.end,
3076                "unescaped-dot-in-hostname-like-pattern".to_owned(),
3077            ));
3078        }
3079    }
3080
3081    fn classify_bad_tag(&mut self) {
3082        if self.incomplete.is_some() || !self.flags.complete {
3083            return;
3084        }
3085        let value = &self.decoded.value;
3086        if !(value.contains('<') && value.contains('>'))
3087            && !["-->", "--!>", "<--", "<!--"]
3088                .iter()
3089                .any(|marker| value.contains(marker))
3090        {
3091            return;
3092        }
3093        let rust_pattern = value
3094            .replace("\\/", "/")
3095            .replace("\\\"", "\"")
3096            .replace("\\'", "'");
3097        let mut builder = regex::RegexBuilder::new(&rust_pattern);
3098        builder
3099            .case_insensitive(self.flags.ignorecase)
3100            .multi_line(self.flags.multiline)
3101            .dot_matches_new_line(self.flags.dotall)
3102            .unicode(!self.flags.ascii)
3103            .ignore_whitespace(self.flags.verbose);
3104        let Ok(regex) = builder.build() else {
3105            return;
3106        };
3107        let capture_nfa = match RegexNfa::compile(&self.facts, 0, None) {
3108            Ok(nfa) => Some(nfa),
3109            Err(error) => {
3110                self.mark_incomplete(
3111                    0,
3112                    self.chars.len(),
3113                    match error {
3114                        NfaError::Unsupported => "bad-tag-nfa-unsupported",
3115                        NfaError::Budget => "bad-tag-nfa-state-budget",
3116                    },
3117                );
3118                return;
3119            }
3120        };
3121        let matches = |sample: &str| {
3122            capture_nfa.as_ref().map_or_else(
3123                || regex.is_match(sample),
3124                |nfa| nfa.match_prefix_capture_groups(sample).0,
3125            )
3126        };
3127        let groups = |sample: &str| {
3128            capture_nfa.as_ref().map_or_else(
3129                || {
3130                    regex
3131                        .captures(sample)
3132                        .map(|captures| {
3133                            captures
3134                                .iter()
3135                                .enumerate()
3136                                .skip(1)
3137                                .filter_map(|(index, capture)| capture.map(|_| index))
3138                                .collect::<HashSet<_>>()
3139                        })
3140                        .unwrap_or_default()
3141                },
3142                |nfa| nfa.match_prefix_capture_groups(sample).1,
3143            )
3144        };
3145        let comment = "<!-- foo -->";
3146        let comment_bang = "<!-- foo --!>";
3147        let comment_groups = groups(comment);
3148        let comment_bang_groups = groups(comment_bang);
3149        let comment_group_mismatch = matches(comment)
3150            && matches(comment_bang)
3151            && comment_groups.iter().any(|group| {
3152                !comment_bang_groups.contains(group)
3153                    && comment_bang_groups.iter().any(|other| group != other)
3154            });
3155        let plain_tag_groups = groups("<foo>");
3156        let only_comment_group = groups(comment).iter().any(|group| {
3157            plain_tag_groups.iter().any(|other| other != group)
3158                && !matches(comment_bang)
3159                && !groups("<!- foo ->").contains(group)
3160                && !plain_tag_groups.contains(group)
3161                && !groups("<script>").contains(group)
3162        });
3163        let bounded_script_opener = value
3164            .to_ascii_lowercase()
3165            .find("<script")
3166            .map(|start| {
3167                let start = value[..start].chars().count() + "<script".len();
3168                self.facts
3169                    .iter()
3170                    .filter(|fact| {
3171                        fact.kind == NormalizedKind::RegexLiteral
3172                            && fact.construct.as_deref() == Some(">")
3173                            && fact.start >= start
3174                            && !fact.parent.is_some_and(|parent| {
3175                                self.facts[parent].kind == NormalizedKind::RegexCharacterClass
3176                            })
3177                    })
3178                    .map(|fact| fact.start)
3179                    .min()
3180                    .is_some_and(|tag_end| {
3181                        self.facts.iter().enumerate().any(|(id, fact)| {
3182                            fact.kind == NormalizedKind::RegexCharacterClass
3183                                && fact.start >= start
3184                                && fact.end <= tag_end
3185                                && fact
3186                                    .first_set
3187                                    .as_ref()
3188                                    .is_some_and(|set| !set.contains('>'))
3189                                && self.facts.iter().any(|quantifier| {
3190                                    quantifier.kind == NormalizedKind::RegexQuantifier
3191                                        && quantifier.parent == Some(id)
3192                                        && quantifier
3193                                            .quantifier
3194                                            .is_some_and(|quantifier| quantifier.maximum.is_none())
3195                                })
3196                        })
3197                    })
3198            })
3199            .unwrap_or(false);
3200        let script_warning = !bounded_script_opener
3201            && ((matches("<script>foo</script>")
3202                && matches("<script src=\"foo\"></script>")
3203                && !matches("<foo ></foo>")
3204                && (!matches("<script \n>foo</script>") || !matches("<script >foo\n</script>")))
3205                || (matches("<script>foo</script>")
3206                    && matches("<script src=\"foo\"></script>")
3207                    && !matches("<script src='foo'></script>")
3208                    && !matches("<foo>"))
3209                || (matches("<script>foo</script>")
3210                    && matches("<script src='foo'></script>")
3211                    && !matches("<script src=\"foo\"></script>")
3212                    && !matches("<foo>"))
3213                || (matches("<script>foo</script>")
3214                    && matches("<script src='foo'></script>")
3215                    && !matches("<script\tsrc='foo'></script>")
3216                    && !matches("<foo>")
3217                    && !matches("<foo src=\"foo\"></foo>"))
3218                || (matches("<script>foo</script>")
3219                    && !self.flags.ignorecase
3220                    && !matches("<foo>")
3221                    && !matches("<foo ></foo>")
3222                    && (!matches("<SCRIPT>foo</SCRIPT>")
3223                        || (matches("<SCRIPT>foo</SCRIPT>") && !matches("<sCrIpT>foo</ScRiPt>"))))
3224                || (matches("<script src=\"foo\"></script>")
3225                    && !matches("<foo>")
3226                    && !matches("<foo ></foo>")
3227                    && (!matches("<script src=\"foo\">foo</script >")
3228                        || !matches("<script src=\"foo\">foo</script foo=\"bar\">")
3229                        || !matches("<script src=\"foo\">foo</script\t\n bar>"))));
3230        let warning = comment_group_mismatch
3231            || only_comment_group
3232            || (matches("-->") && !matches("--!>") && !matches("--"))
3233            || (matches(comment)
3234                && !matches("<!-- foo\n -->")
3235                && !matches("<!- foo ->")
3236                && !matches("<foo>")
3237                && !matches("<script>"))
3238            || script_warning;
3239        if warning {
3240            self.pending_risks.push((
3241                NormalizedKind::RegexRiskBadTag,
3242                0,
3243                0,
3244                self.chars.len(),
3245                "matches-html-samples-inconsistently".to_owned(),
3246            ));
3247        }
3248    }
3249
3250    fn classify_polynomial_redos(&mut self) {
3251        if self.incomplete.is_some() || !self.flags.complete {
3252            return;
3253        }
3254        let sequences = self
3255            .facts
3256            .iter()
3257            .enumerate()
3258            .filter(|(_, fact)| fact.kind == NormalizedKind::RegexSequence)
3259            .map(|(id, _)| id)
3260            .collect::<Vec<_>>();
3261        for sequence in sequences {
3262            let mut branches = vec![Vec::new()];
3263            for (id, fact) in self.facts.iter().enumerate() {
3264                if fact.parent != Some(sequence) {
3265                    continue;
3266                }
3267                if fact.kind == NormalizedKind::RegexAlternation {
3268                    branches.push(Vec::new());
3269                } else {
3270                    branches
3271                        .last_mut()
3272                        .expect("regex sequence always has a branch")
3273                        .push(id);
3274                }
3275            }
3276            for branch in branches {
3277                let Some(&terminal_anchor) = branch.last() else {
3278                    continue;
3279                };
3280                let terminal = &self.facts[terminal_anchor];
3281                if terminal.kind != NormalizedKind::RegexAnchor
3282                    || terminal.construct.as_deref() != Some("$")
3283                {
3284                    continue;
3285                }
3286
3287                let mut repeated = Vec::new();
3288                for (position, &atom) in branch[..branch.len() - 1].iter().enumerate() {
3289                    if !matches!(
3290                        self.facts[atom].kind,
3291                        NormalizedKind::RegexLiteral
3292                            | NormalizedKind::RegexEscape
3293                            | NormalizedKind::RegexCharacterClass
3294                            | NormalizedKind::RegexWildcard
3295                    ) || self.facts[atom].minimum_width != Some(1)
3296                    {
3297                        continue;
3298                    }
3299                    let Some((quantifier_id, quantifier)) =
3300                        self.facts.iter().enumerate().find_map(|(id, fact)| {
3301                            (fact.kind == NormalizedKind::RegexQuantifier
3302                                && fact.parent == Some(atom))
3303                            .then_some((id, fact.quantifier?))
3304                        })
3305                    else {
3306                        continue;
3307                    };
3308                    if quantifier.minimum == 1
3309                        && quantifier.maximum.is_none()
3310                        && !quantifier.possessive
3311                    {
3312                        repeated.push((
3313                            position,
3314                            atom,
3315                            quantifier_id,
3316                            self.facts[atom].first_set.clone(),
3317                        ));
3318                    }
3319                }
3320                if self.flags.ignorecase && !self.flags.ascii && !repeated.is_empty() {
3321                    self.mark_incomplete(
3322                        self.facts[sequence].start,
3323                        self.facts[sequence].end,
3324                        "redos-unicode-casefold-bound",
3325                    );
3326                    continue;
3327                }
3328
3329                let anchored_at_start = branch
3330                    .iter()
3331                    .take_while(|&&id| {
3332                        (self.facts[id].kind == NormalizedKind::RegexAnchor
3333                            && self.facts[id].construct.as_deref() == Some("^"))
3334                            || (self.facts[id].kind == NormalizedKind::RegexGroup
3335                                && self.facts[id].construct.as_deref() == Some("comment"))
3336                    })
3337                    .any(|&id| {
3338                        self.facts[id].kind == NormalizedKind::RegexAnchor
3339                            && self.facts[id].construct.as_deref() == Some("^")
3340                    });
3341                let mut risky_quantifier = None;
3342                let mut overlapping_repeated_runs = false;
3343                'pairs: for left in 0..repeated.len() {
3344                    for right in left + 1..repeated.len() {
3345                        let (left_position, left_atom, _, _) = &repeated[left];
3346                        let (right_position, right_atom, right_quantifier, _) = &repeated[right];
3347                        if !polynomial_atoms_overlap(
3348                            &self.facts[*left_atom],
3349                            &self.facts[*right_atom],
3350                        ) {
3351                            continue;
3352                        }
3353                        let separable =
3354                            branch[left_position + 1..*right_position]
3355                                .iter()
3356                                .all(|&separator| {
3357                                    if !matches!(
3358                                        self.facts[separator].kind,
3359                                        NormalizedKind::RegexLiteral
3360                                            | NormalizedKind::RegexEscape
3361                                            | NormalizedKind::RegexCharacterClass
3362                                            | NormalizedKind::RegexWildcard
3363                                    ) {
3364                                        return false;
3365                                    }
3366                                    let Some(quantifier) = self
3367                                        .facts
3368                                        .iter()
3369                                        .find(|fact| {
3370                                            fact.kind == NormalizedKind::RegexQuantifier
3371                                                && fact.parent == Some(separator)
3372                                        })
3373                                        .and_then(|fact| fact.quantifier)
3374                                    else {
3375                                        return false;
3376                                    };
3377                                    quantifier.minimum == 0
3378                                        && quantifier.maximum == Some(1)
3379                                        && !quantifier.possessive
3380                                });
3381                        if separable {
3382                            risky_quantifier = Some(*right_quantifier);
3383                            overlapping_repeated_runs = true;
3384                            break 'pairs;
3385                        }
3386                    }
3387                }
3388
3389                if risky_quantifier.is_none() && self.api.scans_from_each_offset {
3390                    risky_quantifier = (!anchored_at_start)
3391                        .then(|| {
3392                            repeated
3393                                .first()
3394                                .map(|(_, _, quantifier_id, _)| *quantifier_id)
3395                        })
3396                        .flatten();
3397                }
3398
3399                if let Some(quantifier) = risky_quantifier {
3400                    self.pending_risks.push((
3401                        NormalizedKind::RegexRiskPolynomial,
3402                        quantifier,
3403                        self.facts[quantifier].start,
3404                        self.facts[quantifier].end,
3405                        if overlapping_repeated_runs {
3406                            "overlapping-repeated-runs".to_owned()
3407                        } else {
3408                            "unanchored-repeated-run-before-end-anchor".to_owned()
3409                        },
3410                    ));
3411                }
3412            }
3413        }
3414    }
3415
3416    fn has_rejecting_suffix(&self, repeated_group: usize) -> bool {
3417        let mut member = repeated_group;
3418        loop {
3419            let Some(sequence) = self.facts[member].parent else {
3420                return false;
3421            };
3422            if self.facts[sequence].kind != NormalizedKind::RegexSequence {
3423                return false;
3424            }
3425            let mut after_member = false;
3426            for (id, fact) in self.facts.iter().enumerate() {
3427                if fact.parent != Some(sequence) {
3428                    continue;
3429                }
3430                if id == member {
3431                    after_member = true;
3432                    continue;
3433                }
3434                if !after_member {
3435                    continue;
3436                }
3437                if fact.kind == NormalizedKind::RegexAlternation {
3438                    break;
3439                }
3440                if fact.kind == NormalizedKind::RegexAnchor
3441                    && matches!(
3442                        fact.construct.as_deref(),
3443                        Some("$" | "boundary:Z" | "boundary:z")
3444                    )
3445                {
3446                    return true;
3447                }
3448                if fact.kind == NormalizedKind::RegexGroup
3449                    && fact.construct.as_deref() == Some("lookahead-positive")
3450                    && self.facts.iter().any(|body| {
3451                        body.kind == NormalizedKind::RegexSequence
3452                            && body.parent == Some(id)
3453                            && body.minimum_width.is_some_and(|width| width > 0)
3454                    })
3455                {
3456                    return true;
3457                }
3458                if fact.minimum_width.is_some_and(|width| width > 0) {
3459                    return true;
3460                }
3461            }
3462            let Some(owner) = self.facts[sequence].parent else {
3463                return false;
3464            };
3465            if self.facts[owner].kind != NormalizedKind::RegexGroup {
3466                return false;
3467            }
3468            member = owner;
3469        }
3470    }
3471
3472    fn suffix_is_covered_by_repeated_body(&self, repeated_group: usize) -> bool {
3473        let Some(body_set) = self.facts[repeated_group].first_set.as_ref() else {
3474            return false;
3475        };
3476        let mut member = repeated_group;
3477        loop {
3478            let Some(sequence) = self.facts[member].parent else {
3479                return false;
3480            };
3481            if self.facts[sequence].kind != NormalizedKind::RegexSequence {
3482                return false;
3483            }
3484            let mut after_member = false;
3485            let mut found_suffix = false;
3486            for (id, fact) in self.facts.iter().enumerate() {
3487                if fact.parent != Some(sequence) {
3488                    continue;
3489                }
3490                if fact.kind == NormalizedKind::RegexAlternation {
3491                    break;
3492                }
3493                if id == member {
3494                    after_member = true;
3495                    continue;
3496                }
3497                if !after_member {
3498                    continue;
3499                }
3500                if fact.kind == NormalizedKind::RegexAnchor {
3501                    return false;
3502                }
3503                if fact.minimum_width.is_some_and(|width| width > 0) {
3504                    found_suffix = true;
3505                    if fact.first_set.as_ref() != Some(body_set) {
3506                        return false;
3507                    }
3508                }
3509            }
3510            if found_suffix {
3511                return true;
3512            }
3513            let Some(owner) = self.facts[sequence].parent else {
3514                return false;
3515            };
3516            if self.facts[owner].kind != NormalizedKind::RegexGroup {
3517                return false;
3518            }
3519            member = owner;
3520        }
3521    }
3522
3523    fn classify_redos(&mut self) {
3524        if self.incomplete.is_some() {
3525            return;
3526        }
3527        let groups = (0..self.facts.len())
3528            .filter(|&id| self.facts[id].kind == NormalizedKind::RegexGroup)
3529            .collect::<Vec<_>>();
3530        let mut repeated_groups = Vec::new();
3531        for group in groups {
3532            let Some(sequence) = self.facts.iter().position(|fact| {
3533                fact.kind == NormalizedKind::RegexSequence && fact.parent == Some(group)
3534            }) else {
3535                continue;
3536            };
3537            let Some(outer_quantifier) = self.facts.iter().position(|fact| {
3538                fact.kind == NormalizedKind::RegexQuantifier && fact.parent == Some(group)
3539            }) else {
3540                continue;
3541            };
3542            let Some(quantifier) = self.facts[outer_quantifier].quantifier else {
3543                continue;
3544            };
3545            if quantifier.maximum.is_some() {
3546                continue;
3547            }
3548            if quantifier.possessive {
3549                continue;
3550            }
3551            repeated_groups.push((group, sequence, outer_quantifier));
3552        }
3553        if repeated_groups.len() > MAX_REDOSE_GROUPS {
3554            self.mark_incomplete(0, self.chars.len(), "redos-group-budget");
3555            return;
3556        }
3557        let mut relation_budget = MAX_NFA_PRODUCT_PAIRS;
3558        for (group, sequence, outer_quantifier) in repeated_groups {
3559            let mut risk_found = false;
3560            let branch_count = self
3561                .facts
3562                .iter()
3563                .filter(|fact| {
3564                    fact.kind == NormalizedKind::RegexAlternation && fact.parent == Some(sequence)
3565                })
3566                .count()
3567                + 1;
3568            if branch_count > 1 {
3569                if branch_count > MAX_REDOSE_ALTERNATIVES {
3570                    self.mark_incomplete(
3571                        self.facts[group].start,
3572                        self.facts[group].end,
3573                        "redos-alternative-budget",
3574                    );
3575                    continue;
3576                }
3577                if self.flags.ignorecase && !self.flags.ascii {
3578                    self.mark_incomplete(
3579                        self.facts[group].start,
3580                        self.facts[group].end,
3581                        "redos-unicode-casefold-bound",
3582                    );
3583                }
3584                let branches = (0..branch_count)
3585                    .map(|branch| RegexNfa::compile(&self.facts, sequence, Some(branch)))
3586                    .collect::<Result<Vec<_>, _>>();
3587                let repeated = RegexNfa::compile(&self.facts, sequence, None)
3588                    .and_then(RegexNfa::positive_closure);
3589                match (branches, repeated) {
3590                    (Ok(branches), Ok(repeated)) => {
3591                        let mut overlap = false;
3592                        let mut budget_exceeded = false;
3593                        'pairs: for left in 0..branches.len() {
3594                            for right in left + 1..branches.len() {
3595                                match branches[left].intersects_on_nonempty_string(
3596                                    &branches[right],
3597                                    &mut relation_budget,
3598                                ) {
3599                                    Ok(true) => {
3600                                        overlap = true;
3601                                        break 'pairs;
3602                                    }
3603                                    Ok(false) => {}
3604                                    Err(NfaError::Budget) => {
3605                                        budget_exceeded = true;
3606                                        break 'pairs;
3607                                    }
3608                                    Err(NfaError::Unsupported) => unreachable!(
3609                                        "product comparison only rejects bounded automata"
3610                                    ),
3611                                }
3612                                match branches[left].has_repartitioned_prefix(
3613                                    &branches[right],
3614                                    &repeated,
3615                                    &mut relation_budget,
3616                                ) {
3617                                    Ok(true) => {
3618                                        overlap = true;
3619                                        break 'pairs;
3620                                    }
3621                                    Ok(false) => {}
3622                                    Err(NfaError::Budget) => {
3623                                        budget_exceeded = true;
3624                                        break 'pairs;
3625                                    }
3626                                    Err(NfaError::Unsupported) => unreachable!(
3627                                        "prefix comparison only rejects bounded automata"
3628                                    ),
3629                                }
3630                                match branches[right].has_repartitioned_prefix(
3631                                    &branches[left],
3632                                    &repeated,
3633                                    &mut relation_budget,
3634                                ) {
3635                                    Ok(true) => {
3636                                        overlap = true;
3637                                        break 'pairs;
3638                                    }
3639                                    Ok(false) => {}
3640                                    Err(NfaError::Budget) => {
3641                                        budget_exceeded = true;
3642                                        break 'pairs;
3643                                    }
3644                                    Err(NfaError::Unsupported) => unreachable!(
3645                                        "prefix comparison only rejects bounded automata"
3646                                    ),
3647                                }
3648                            }
3649                        }
3650                        if overlap && self.has_rejecting_suffix(group) {
3651                            risk_found = true;
3652                            self.pending_risks.push((
3653                                NormalizedKind::RegexRiskExponential,
3654                                outer_quantifier,
3655                                self.facts[outer_quantifier].start,
3656                                self.facts[outer_quantifier].end,
3657                                "overlapping-repeated-alternatives".to_owned(),
3658                            ));
3659                        }
3660                        if budget_exceeded {
3661                            self.mark_incomplete(
3662                                self.facts[group].start,
3663                                self.facts[group].end,
3664                                "redos-nfa-product-budget",
3665                            );
3666                        }
3667                    }
3668                    (Err(error), _) | (_, Err(error)) => self.mark_incomplete(
3669                        self.facts[group].start,
3670                        self.facts[group].end,
3671                        match error {
3672                            NfaError::Unsupported => "redos-nfa-unsupported",
3673                            NfaError::Budget => "redos-nfa-state-budget",
3674                        },
3675                    ),
3676                }
3677            }
3678            let has_backtracking_child = (0..self.facts.len()).any(|candidate| {
3679                candidate != outer_quantifier
3680                    && self.is_descendant(candidate, group)
3681                    && matches!(
3682                        self.facts[candidate].kind,
3683                        NormalizedKind::RegexAlternation | NormalizedKind::RegexQuantifier
3684                    )
3685            });
3686            if has_backtracking_child && !risk_found {
3687                if self.flags.ignorecase && !self.flags.ascii {
3688                    self.mark_incomplete(
3689                        self.facts[group].start,
3690                        self.facts[group].end,
3691                        "redos-unicode-casefold-bound",
3692                    );
3693                }
3694                match RegexNfa::compile(&self.facts, group, None)
3695                    .and_then(|nfa| nfa.has_pumpable_fork(&mut relation_budget))
3696                {
3697                    Ok(true)
3698                        if self.has_rejecting_suffix(group)
3699                            && !self.suffix_is_covered_by_repeated_body(group) =>
3700                    {
3701                        self.pending_risks.push((
3702                            NormalizedKind::RegexRiskExponential,
3703                            outer_quantifier,
3704                            self.facts[outer_quantifier].start,
3705                            self.facts[outer_quantifier].end,
3706                            "overlapping-nfa-cycles".to_owned(),
3707                        ));
3708                    }
3709                    Ok(_) => {}
3710                    Err(error) => self.mark_incomplete(
3711                        self.facts[group].start,
3712                        self.facts[group].end,
3713                        match error {
3714                            NfaError::Unsupported => "redos-nfa-unsupported",
3715                            NfaError::Budget => "redos-nfa-product-budget",
3716                        },
3717                    ),
3718                }
3719            }
3720        }
3721    }
3722
3723    fn is_descendant(&self, mut fact: usize, ancestor: usize) -> bool {
3724        while let Some(parent) = self.facts[fact].parent {
3725            if parent == ancestor {
3726                return true;
3727            }
3728            fact = parent;
3729        }
3730        false
3731    }
3732
3733    fn append_pending_facts(&mut self) {
3734        for (kind, parent, start, end, reason) in std::mem::take(&mut self.pending_risks) {
3735            if self.facts.len() >= MAX_PATTERN_FACTS - 1 {
3736                self.exceeded = true;
3737                break;
3738            }
3739            self.add_fact(kind, start, end, Some(parent), Some(reason), None);
3740        }
3741        if let Some((start, end, reason)) = self.incomplete.take() {
3742            self.add_fact(
3743                NormalizedKind::RegexIncomplete,
3744                start,
3745                end,
3746                Some(0),
3747                Some(reason),
3748                None,
3749            );
3750        }
3751    }
3752}
3753
3754fn is_overly_broad_range(start: char, end: char) -> bool {
3755    let low = start as u32;
3756    let high = end as u32;
3757    let width = high.saturating_sub(low).saturating_add(1);
3758    if width < 10 {
3759        return false;
3760    }
3761    let alphanumeric = |character: char| character.is_ascii_alphanumeric();
3762    let wide_ascii = low < 128 && high < 128;
3763    let spans_letters = low <= 'Z' as u32 && high >= 'a' as u32;
3764    let spans_digits_to_letters = low <= '9' as u32 && high >= 'A' as u32;
3765    let has_non_alphanumeric_boundary = !alphanumeric(start) || !alphanumeric(end);
3766    let allowed = end == '~'
3767        || matches!(start, ' ' | '!')
3768        || start == '\0'
3769        || (start == '0' && end == '?')
3770        || (start == '@' && end == '_')
3771        || (start == '@' && end == 'Z');
3772    !allowed
3773        && (spans_letters
3774            || spans_digits_to_letters
3775            || (wide_ascii && has_non_alphanumeric_boundary))
3776}
3777
3778fn codeql_range_character_set(start: char, end: char) -> CharacterSet {
3779    let codepoint = |character: char| {
3780        let value = character as u32;
3781        if value > 0xffff { 0xfffd } else { value }
3782    };
3783    let start = codepoint(start);
3784    let end = codepoint(end);
3785    CharacterSet {
3786        ranges: (start <= end).then_some((start, end)).into_iter().collect(),
3787    }
3788}
3789
3790fn category_character_set(escaped: char, ascii: bool) -> Option<CharacterSet> {
3791    let (category, negated) = match escaped {
3792        'd' => ('d', false),
3793        'D' => ('d', true),
3794        's' => ('s', false),
3795        'S' => ('s', true),
3796        'w' => ('w', false),
3797        'W' => ('w', true),
3798        _ => return None,
3799    };
3800    let mut set = match category {
3801        'd' => CharacterSet {
3802            ranges: vec![(b'0' as u32, b'9' as u32)],
3803        },
3804        's' => CharacterSet {
3805            ranges: vec![(9, 13), (32, 32)],
3806        },
3807        'w' => CharacterSet {
3808            ranges: vec![
3809                (b'0' as u32, b'9' as u32),
3810                (b'A' as u32, b'Z' as u32),
3811                (b'_' as u32, b'_' as u32),
3812                (b'a' as u32, b'z' as u32),
3813            ],
3814        },
3815        _ => unreachable!("category is selected from d, s, w"),
3816    };
3817    if !ascii && negated {
3818        // A Unicode negated category is not the complement of its ASCII
3819        // subset. Leave it unknown rather than claim a false overlap.
3820    }
3821    if !ascii && negated {
3822        return None;
3823    }
3824    if negated {
3825        set = set.complement();
3826    }
3827    Some(set)
3828}
3829
3830fn parse_regex_escape(
3831    chars: &[char],
3832    start: usize,
3833    in_class: bool,
3834    ascii: bool,
3835) -> Option<EscapeValue> {
3836    if chars.get(start) != Some(&'\\') {
3837        return None;
3838    }
3839    let escaped = *chars.get(start + 1)?;
3840    let mut end = start + 2;
3841    let mut value = None;
3842    let mut anchor = false;
3843    let mut incomplete = None;
3844    let mut backspace = false;
3845    let construct = match escaped {
3846        'A' | 'Z' | 'z' | 'b' | 'B' if !in_class => {
3847            anchor = true;
3848            Some(format!("boundary:{escaped}"))
3849        }
3850        'd' | 'D' | 's' | 'S' | 'w' | 'W' => Some(format!("category:{escaped}")),
3851        'a' => {
3852            value = Some('\u{7}');
3853            Some("U+0007".to_owned())
3854        }
3855        'f' => {
3856            value = Some('\u{c}');
3857            Some("U+000C".to_owned())
3858        }
3859        'n' => {
3860            value = Some('\n');
3861            Some("U+000A".to_owned())
3862        }
3863        'r' => {
3864            value = Some('\r');
3865            Some("U+000D".to_owned())
3866        }
3867        't' => {
3868            value = Some('\t');
3869            Some("U+0009".to_owned())
3870        }
3871        'v' => {
3872            value = Some('\u{b}');
3873            Some("U+000B".to_owned())
3874        }
3875        'b' if in_class => {
3876            value = Some('\u{8}');
3877            backspace = true;
3878            Some("backspace".to_owned())
3879        }
3880        'x' | 'u' | 'U' => {
3881            let digits = match escaped {
3882                'x' => 2,
3883                'u' => 4,
3884                _ => 8,
3885            };
3886            let scalar = hex_value(chars, start + 2, digits);
3887            end = start + 2 + digits;
3888            match scalar.and_then(char::from_u32) {
3889                Some(character) => {
3890                    value = Some(character);
3891                    Some(format!("U+{:04X}", character as u32))
3892                }
3893                None => {
3894                    incomplete = Some("invalid-hex-escape");
3895                    Some(format!("invalid:{escaped}"))
3896                }
3897            }
3898        }
3899        'N' => {
3900            incomplete = Some("named-unicode-escape");
3901            if chars.get(start + 2) == Some(&'{') {
3902                let mut close = start + 3;
3903                while close < chars.len() && chars[close] != '}' {
3904                    close += 1;
3905                }
3906                end = (close + 1).min(chars.len());
3907            }
3908            Some("unicode-name".to_owned())
3909        }
3910        '0'..='7' => {
3911            let mut codepoint = escaped as u32 - '0' as u32;
3912            let mut digits = 1usize;
3913            while digits < 3 {
3914                let Some(digit @ '0'..='7') = chars.get(start + 1 + digits).copied() else {
3915                    break;
3916                };
3917                codepoint = codepoint * 8 + (digit as u32 - '0' as u32);
3918                digits += 1;
3919            }
3920            end = start + 1 + digits;
3921            match char::from_u32(codepoint) {
3922                Some(character) => {
3923                    value = Some(character);
3924                    Some(format!("U+{codepoint:04X}"))
3925                }
3926                None => {
3927                    incomplete = Some("invalid-octal-escape");
3928                    Some("invalid-octal".to_owned())
3929                }
3930            }
3931        }
3932        '1'..='9' if !in_class => {
3933            incomplete = Some("backreference-not-supported");
3934            Some(format!("backreference:{escaped}"))
3935        }
3936        'g' | 'k' if !in_class => {
3937            incomplete = Some("named-backreference-not-supported");
3938            Some(format!("backreference:{escaped}"))
3939        }
3940        other if other.is_ascii_alphabetic() => {
3941            incomplete = Some("unknown-regex-escape");
3942            Some(format!("unknown:{other}"))
3943        }
3944        other => {
3945            value = Some(other);
3946            Some(other.to_string())
3947        }
3948    };
3949    if end > chars.len() {
3950        return None;
3951    }
3952    let first_set = value
3953        .map(CharacterSet::one)
3954        .or_else(|| category_character_set(escaped, ascii));
3955    Some(EscapeValue {
3956        end,
3957        anchor,
3958        construct,
3959        first_set,
3960        incomplete,
3961        backspace,
3962    })
3963}
3964
3965fn parse_class_atom(chars: &[char], start: usize, ascii: bool) -> Option<ClassAtom> {
3966    let value = *chars.get(start)?;
3967    if value != '\\' {
3968        return Some(ClassAtom {
3969            start,
3970            end: start + 1,
3971            value: Some(value),
3972            set: Some(CharacterSet::one(value)),
3973            kind: NormalizedKind::RegexLiteral,
3974            construct: Some(value.to_string()),
3975            backspace: false,
3976            incomplete: None,
3977        });
3978    }
3979    let escape = parse_regex_escape(chars, start, true, ascii)?;
3980    Some(ClassAtom {
3981        start,
3982        end: escape.end,
3983        value: escape.first_set.as_ref().and_then(single_character),
3984        set: escape.first_set,
3985        kind: NormalizedKind::RegexEscape,
3986        construct: escape.construct,
3987        backspace: escape.backspace,
3988        incomplete: escape.incomplete,
3989    })
3990}
3991
3992fn single_character(set: &CharacterSet) -> Option<char> {
3993    let [(start, end)] = set.ranges.as_slice() else {
3994        return None;
3995    };
3996    (start == end).then(|| char::from_u32(*start)).flatten()
3997}
3998
3999fn facts_to_embedded(
4000    facts: Vec<Fact>,
4001    decoded: &DecodedValue,
4002    source: &str,
4003) -> Vec<EmbeddedLeafFact> {
4004    let mut children = vec![Vec::new(); facts.len()];
4005    let mut roots = Vec::new();
4006    for (id, fact) in facts.iter().enumerate() {
4007        if let Some(parent) = fact.parent {
4008            assert!(
4009                parent < id,
4010                "regex facts are parent-before-child before ordering"
4011            );
4012            children[parent].push(id);
4013        } else {
4014            roots.push(id);
4015        }
4016    }
4017    let order_children = |ids: &mut Vec<usize>| {
4018        ids.sort_by_key(|id| (facts[*id].start, facts[*id].end, *id));
4019    };
4020    order_children(&mut roots);
4021    for child_ids in &mut children {
4022        order_children(child_ids);
4023    }
4024    let mut order = Vec::with_capacity(facts.len());
4025    let mut stack = roots.into_iter().rev().collect::<Vec<_>>();
4026    while let Some(id) = stack.pop() {
4027        order.push(id);
4028        stack.extend(children[id].iter().rev().copied());
4029    }
4030    assert_eq!(
4031        order.len(),
4032        facts.len(),
4033        "regex AST fact tree reaches every node"
4034    );
4035    let mut remap = vec![0usize; facts.len()];
4036    for (new, old) in order.iter().copied().enumerate() {
4037        remap[old] = new;
4038    }
4039    let line_starts = compute_line_starts(source);
4040    order
4041        .into_iter()
4042        .map(|old| {
4043            let fact = &facts[old];
4044            let range = mapped_source_range(fact.start, fact.end, decoded, &line_starts);
4045            EmbeddedLeafFact {
4046                kind: fact.kind,
4047                range,
4048                parent: fact.parent.map(|parent| remap[parent]),
4049                construct: fact.construct.clone(),
4050                occurrence_role: None,
4051            }
4052        })
4053        .collect()
4054}
4055
4056fn mapped_source_range(
4057    start: usize,
4058    end: usize,
4059    decoded: &DecodedValue,
4060    line_starts: &[usize],
4061) -> Range {
4062    assert!(
4063        start <= end && end <= decoded.chars.len(),
4064        "regex parser ranges stay within the decoded pattern"
4065    );
4066    let (start_byte, end_byte) = if start < end {
4067        (
4068            decoded.chars[start].source_start,
4069            decoded.chars[end - 1].source_end,
4070        )
4071    } else {
4072        // An empty regex sequence (for example the body of `()`) still has a
4073        // precise source boundary. Mapping it to the whole Python literal
4074        // makes that child extend outside its enclosing group.
4075        let boundary = decoded.chars.get(start).map_or_else(
4076            || {
4077                decoded
4078                    .chars
4079                    .last()
4080                    .map_or(decoded.source_range.start_byte, |last| last.source_end)
4081            },
4082            |next| next.source_start,
4083        );
4084        (boundary, boundary)
4085    };
4086    Range {
4087        start_byte,
4088        end_byte,
4089        start_line: find_line_index_for_offset(line_starts, start_byte) + 1,
4090        end_line: find_line_index_for_offset(
4091            line_starts,
4092            end_byte.saturating_sub(1).max(start_byte),
4093        ) + 1,
4094    }
4095}
4096
4097#[cfg(test)]
4098mod tests {
4099    use super::*;
4100    use brokk_bifrost_test_macros::hardwon;
4101
4102    #[test]
4103    fn public_string_value_decoder_decodes_escaped_format_braces() {
4104        let source = r#"value = "\x7b0\x7d""#;
4105        let mut parser = tree_sitter::Parser::new();
4106        parser
4107            .set_language(&tree_sitter_python::LANGUAGE.into())
4108            .expect("Python grammar is valid");
4109        let tree = parser.parse(source, None).expect("parser returns a tree");
4110        let mut pending = vec![tree.root_node()];
4111        let mut string = None;
4112        while let Some(node) = pending.pop() {
4113            if node.kind() == "string" {
4114                string = Some(node);
4115                break;
4116            }
4117            let mut cursor = node.walk();
4118            pending.extend(node.named_children(&mut cursor));
4119        }
4120        let string = string.expect("fixture contains a string expression");
4121        let mut cursor = string.walk();
4122        let parts = string
4123            .named_children(&mut cursor)
4124            .map(|child| (child.kind().to_owned(), node_text(child, source).to_owned()))
4125            .collect::<Vec<_>>();
4126        assert_eq!(
4127            decode_python_string_value(string, source).expect("string literal decodes"),
4128            "{0}",
4129            "parts={parts:?}; string={}",
4130            string.to_sexp()
4131        );
4132    }
4133
4134    fn embedded_for_source(source: &str) -> Vec<EmbeddedLeafFact> {
4135        let mut parser = tree_sitter::Parser::new();
4136        parser
4137            .set_language(&tree_sitter_python::LANGUAGE.into())
4138            .expect("Python grammar is valid");
4139        let tree = parser.parse(source, None).expect("parser returns a tree");
4140        let root = tree.root_node();
4141        let mut pending = vec![root];
4142        let mut facts = module_assigned_facts(root, source);
4143        while let Some(node) = pending.pop() {
4144            if matches!(node.kind(), "call" | "string" | "concatenated_string") {
4145                facts.extend(embedded_facts(node, source));
4146            }
4147            let mut children = Vec::new();
4148            let mut cursor = node.walk();
4149            for child in node.named_children(&mut cursor) {
4150                children.push(child);
4151            }
4152            pending.extend(children.into_iter().rev());
4153        }
4154        facts
4155    }
4156
4157    #[hardwon(
4158        "#4874: bounded repetition cloned a fragment after concatenation linked its end outside"
4159    )]
4160    #[test]
4161    fn meshroom_assigned_regex_and_reduced_form_compile_without_incomplete_facts() {
4162        let patterns = [
4163            r"(?P<FILESTEM_PREFIX>.*?)(?P<FRAMEID_STR>[-._]\d+)?(?P<EXTENSION>\.\w{3,4})",
4164            r"(?P<run>a{3})",
4165        ];
4166
4167        for pattern in patterns {
4168            let source = format!("import re\npattern = r\"{pattern}\"\nre.compile(pattern)\n");
4169            let facts = embedded_for_source(&source);
4170            assert!(
4171                facts
4172                    .iter()
4173                    .any(|fact| fact.kind == NormalizedKind::RegexPattern),
4174                "pattern={pattern:?}; facts={facts:#?}"
4175            );
4176            assert!(
4177                facts
4178                    .iter()
4179                    .all(|fact| fact.kind != NormalizedKind::RegexIncomplete),
4180                "pattern={pattern:?}; facts={facts:#?}"
4181            );
4182        }
4183
4184        let unsupported = embedded_for_source(
4185            "import re\npattern = r\"(?P<anchored>^a)\"\nre.compile(pattern)\n",
4186        );
4187        assert!(
4188            unsupported.iter().any(|fact| {
4189                fact.kind == NormalizedKind::RegexIncomplete
4190                    && fact.construct.as_deref() == Some("bad-tag-nfa-unsupported")
4191            }),
4192            "unsupported NFA construct must be explicit; facts={unsupported:#?}"
4193        );
4194    }
4195
4196    #[hardwon(
4197        "#4650: Python regex facts missed byte patterns, nested assigned values, Unicode ranges, and ReDoS"
4198    )]
4199    #[test]
4200    fn codeql_regex_facts_cover_byte_values_assignments_and_safe_redos_controls() {
4201        let source = r####"import re
4202re.compile(br"[\b\t ]")
4203re.compile(b"[AA]")
4204re.compile(br"(P<name>[\w]+)")
4205re.compile(b" ^abc")
4206re.compile(b"(?s) ^abc")
4207re.compile(b"abc$ ")
4208re.compile(r"[A-z]")
4209re.compile(r"^[z-a]$")
4210re.compile(r"[0-93-5]")
4211re.compile(r"[0-9\d]")
4212re.compile('[\U0001D173-\U0001D17A\U000E0020-\U000E007F\U000e0001]')
4213re.compile(r"<script.*?>.*?</script>", re.I)
4214re.compile(r"<script.*?>.*?</script>", re.re.DOTALL)
4215re.compile(r"""(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|<!(--.*?--\s*)+>)""", re.I)
4216VERBOSE_REGEX = r"""
4217        \[
4218        (?P<header>[^]]+)                  # very permissive!
4219        \]
4220        """
4221re.compile(VERBOSE_REGEX, re.VERBOSE)
4222re.compile(r"(a|.)*")
4223re.compile(r"(a|aa?)*b")
4224re.compile(r"^([a-z]+)+$")
4225MY_REG = r'X(\u0061|a)*Y'
4226def matcher(name):
4227    re.match(MY_REG, name)
4228def shadowed(name, MY_REG):
4229    re.match(MY_REG, name)
4230re.compile(r"((.|[^a])*)\"")
4231re.compile(r"((a|[^a])*)\"")
4232re.compile(r"(a+)+aaaaa*a+")
4233"####;
4234        let facts = embedded_for_source(source);
4235        let has = |kind| facts.iter().any(|fact| fact.kind == kind);
4236        assert!(has(NormalizedKind::RegexRiskBackspaceEscape), "{facts:#?}");
4237        assert!(
4238            has(NormalizedKind::RegexRiskDuplicateClassCharacter),
4239            "{facts:#?}"
4240        );
4241        assert!(
4242            has(NormalizedKind::RegexRiskIncompleteSpecialGroup),
4243            "{facts:#?}"
4244        );
4245        assert!(has(NormalizedKind::RegexRiskUnmatchableCaret), "{facts:#?}");
4246        assert!(
4247            has(NormalizedKind::RegexRiskUnmatchableDollar),
4248            "{facts:#?}"
4249        );
4250        assert!(has(NormalizedKind::RegexRiskRange), "{facts:#?}");
4251        assert!(has(NormalizedKind::RegexRiskBadTag), "{facts:#?}");
4252        assert!(has(NormalizedKind::RegexRiskExponential), "{facts:#?}");
4253        let overlapping_range_start = source
4254            .find("[0-93-5]")
4255            .expect("overlapping range sample is present")
4256            + 1;
4257        assert!(
4258            facts.iter().any(|fact| {
4259                fact.kind == NormalizedKind::RegexRiskRange
4260                    && fact.range.start_byte == overlapping_range_start
4261                    && fact.range.end_byte == overlapping_range_start + 3
4262            }),
4263            "{facts:#?}"
4264        );
4265        let line_with = |needle: &str| {
4266            source
4267                .lines()
4268                .position(|line| line.contains(needle))
4269                .expect("regex fixture line is present")
4270                + 1
4271        };
4272        let risk_lines = facts
4273            .iter()
4274            .filter(|fact| fact.kind == NormalizedKind::RegexRiskExponential)
4275            .map(|fact| fact.range.start_line)
4276            .collect::<HashSet<_>>();
4277        assert!(risk_lines.contains(&line_with("^([a-z]+)+$")), "{facts:#?}");
4278        assert!(risk_lines.contains(&line_with("MY_REG =")), "{facts:#?}");
4279        assert!(risk_lines.contains(&line_with("((.|[^a])*)")), "{facts:#?}");
4280        assert!(
4281            !risk_lines.contains(&line_with("((a|[^a])*)")),
4282            "{facts:#?}"
4283        );
4284        assert!(!risk_lines.contains(&line_with("aaaaa*a+")), "{facts:#?}");
4285        let shadowed_line = source
4286            .lines()
4287            .enumerate()
4288            .filter_map(|(index, line)| {
4289                line.contains("re.match(MY_REG, name)").then_some(index + 1)
4290            })
4291            .last()
4292            .expect("shadowed pattern reference is present");
4293        assert!(
4294            facts.iter().any(|fact| {
4295                fact.kind == NormalizedKind::RegexIncomplete
4296                    && fact.range.start_line == shadowed_line
4297                    && fact
4298                        .construct
4299                        .as_deref()
4300                        .is_some_and(|reason| reason.starts_with("dynamic-pattern:"))
4301            }),
4302            "{facts:#?}"
4303        );
4304        assert!(
4305            facts.iter().any(|fact| {
4306                fact.kind == NormalizedKind::RegexRiskRange
4307                    && fact.range.start_line == line_with("U0001D173")
4308            }),
4309            "{facts:#?}"
4310        );
4311        assert!(
4312            facts.iter().any(|fact| {
4313                fact.kind == NormalizedKind::RegexRiskDuplicateClassCharacter
4314                    && fact.range.start_line == line_with("VERBOSE_REGEX =")
4315            }),
4316            "{facts:#?}"
4317        );
4318        assert!(
4319            facts.iter().any(|fact| {
4320                fact.kind == NormalizedKind::RegexRiskBadTag
4321                    && fact.range.start_line == line_with("<[a-z")
4322            }),
4323            "{facts:#?}"
4324        );
4325        let nested_flag_line = source
4326            .lines()
4327            .position(|line| line.contains("re.re.DOTALL"))
4328            .expect("upstream nested DOTALL flag case is present")
4329            + 1;
4330        assert!(facts.iter().any(|fact| {
4331            fact.kind == NormalizedKind::RegexRiskBadTag
4332                && fact.range.start_line == nested_flag_line
4333        }));
4334        let safe_line = source
4335            .lines()
4336            .position(|line| line.contains("(a|.)*"))
4337            .expect("safe redos control is present")
4338            + 1;
4339        assert!(!facts.iter().any(|fact| {
4340            fact.kind == NormalizedKind::RegexRiskExponential && fact.range.start_line == safe_line
4341        }));
4342        assert!(facts.iter().any(|fact| {
4343            fact.kind == NormalizedKind::RegexPattern
4344                && fact
4345                    .construct
4346                    .as_deref()
4347                    .is_some_and(|pattern| pattern.contains("very permissive"))
4348        }));
4349    }
4350
4351    #[test]
4352    fn decodes_python_unknown_escapes_and_adjacent_literals_before_regex_parse() {
4353        let source = r#"import re
4354re.compile("\d" "\." r"\.")
4355"#;
4356        let facts = embedded_for_source(source);
4357        let pattern = facts
4358            .iter()
4359            .find(|fact| fact.kind == NormalizedKind::RegexPattern)
4360            .expect("selected compile call has a regex pattern fact");
4361        assert_eq!(pattern.construct.as_deref(), Some(r"\d\.\."));
4362        assert!(
4363            facts
4364                .iter()
4365                .any(|fact| fact.kind == NormalizedKind::RegexEscape)
4366        );
4367        assert!(
4368            !facts
4369                .iter()
4370                .any(|fact| fact.kind == NormalizedKind::RegexIncomplete)
4371        );
4372    }
4373
4374    #[test]
4375    fn selects_only_recognized_pattern_arguments_and_marks_dynamic_values_incomplete() {
4376        let source = r#"import re
4377re.compile("selected")
4378re.sub("replacement", "pattern", "subject")
4379other.compile("unrecognized")
4380re.compile(pattern)
4381"#;
4382        let facts = embedded_for_source(source);
4383        let patterns = facts
4384            .iter()
4385            .filter(|fact| fact.kind == NormalizedKind::RegexPattern)
4386            .map(|fact| fact.construct.as_deref().expect("pattern stores value"))
4387            .collect::<Vec<_>>();
4388        assert_eq!(patterns, ["selected", "replacement"]);
4389        assert!(facts.iter().any(|fact| {
4390            fact.kind == NormalizedKind::RegexIncomplete
4391                && fact
4392                    .construct
4393                    .as_deref()
4394                    .is_some_and(|construct| construct.starts_with("dynamic-pattern:"))
4395        }));
4396        assert!(!patterns.contains(&"pattern"));
4397        assert!(!patterns.contains(&"subject"));
4398        assert!(!patterns.contains(&"unrecognized"));
4399    }
4400
4401    #[test]
4402    fn supported_lookaround_groups_and_orphan_quantifiers_have_typed_facts() {
4403        let facts = embedded_for_source(
4404            "import re\nre.compile(r\"(?=lookahead)\")\nre.compile(r\"a$*\")\n",
4405        );
4406        assert!(facts.iter().any(|fact| {
4407            fact.kind == NormalizedKind::RegexGroup
4408                && fact.construct.as_deref() == Some("lookahead-positive")
4409        }));
4410        assert!(facts.iter().any(|fact| {
4411            fact.kind == NormalizedKind::RegexIncomplete
4412                && fact.construct.as_deref() == Some("quantifier-without-atom")
4413        }));
4414    }
4415
4416    #[hardwon(
4417        "#4454: zero-width regex facts escaped their embedded parent and aborted workspace indexing"
4418    )]
4419    #[test]
4420    fn zero_width_regex_facts_stay_within_their_embedded_parents() {
4421        for (source, malformed) in [
4422            ("import re\nre.compile(r'^(v)(.*)()$')\n", false),
4423            ("import re\nre.subn(\"()\"r' am I'\"??\")\n", false),
4424            (
4425                "import re\nre.compile(r\"invalid literal for int().*: '?'?\")\n",
4426                false,
4427            ),
4428            ("import re\nre.compile(\"ab()+cd\")\n", false),
4429            ("import re\nre.sub('()x', r'\\g<0>\\g<0>', 'xx')\n", false),
4430            ("import re\nre.search('(', 'test')\n", true),
4431        ] {
4432            let facts = embedded_for_source(source);
4433            assert!(
4434                facts
4435                    .iter()
4436                    .any(|fact| fact.kind == NormalizedKind::RegexPattern),
4437                "source={source:?}; facts={facts:#?}"
4438            );
4439            for (fact_id, fact) in facts.iter().enumerate() {
4440                if let Some(parent) = fact.parent {
4441                    assert!(parent < fact_id, "source={source:?}; facts={facts:#?}");
4442                    let parent_fact = &facts[parent];
4443                    assert!(
4444                        parent_fact.range.start_byte <= fact.range.start_byte
4445                            && fact.range.end_byte <= parent_fact.range.end_byte,
4446                        "source={source:?}; fact={fact:#?}; parent={parent_fact:#?}"
4447                    );
4448                }
4449            }
4450            if !malformed {
4451                assert!(
4452                    facts.iter().any(|fact| {
4453                        fact.kind == NormalizedKind::RegexSequence
4454                            && fact.range.start_byte == fact.range.end_byte
4455                    }),
4456                    "source={source:?} retains its zero-width empty-group sequence; facts={facts:#?}"
4457                );
4458            }
4459            if malformed {
4460                assert!(
4461                    facts
4462                        .iter()
4463                        .any(|fact| fact.kind == NormalizedKind::RegexIncomplete),
4464                    "malformed pattern remains structurally unavailable; facts={facts:#?}"
4465                );
4466            }
4467        }
4468    }
4469
4470    #[test]
4471    fn polynomial_redos_facts_cover_search_restarts_and_overlapping_repetitions() {
4472        for (source, expected) in [
4473            ("import re\nre.search(r'\\s+$', value)\n", true),
4474            ("import re\nre.search(r'^\\s+$', value)\n", false),
4475            ("import re\nre.search(r'^\\d+E?\\d+$', value)\n", true),
4476            ("import re\nre.search(r'^\\d+E\\d+$', value)\n", false),
4477            ("import re\nre.match(r'\\s+$', value)\n", false),
4478            ("import re\nre.compile(r'\\s+$')\n", false),
4479        ] {
4480            let facts = embedded_for_source(source);
4481            assert_eq!(
4482                facts
4483                    .iter()
4484                    .any(|fact| fact.kind == NormalizedKind::RegexRiskPolynomial),
4485                expected,
4486                "source={source:?}; facts={facts:#?}"
4487            );
4488        }
4489    }
4490
4491    #[test]
4492    #[hardwon("#4443: an empty embedded regex sequence escaped its parent source range")]
4493    fn unquantified_empty_group_keeps_embedded_regex_facts_inside_parents() {
4494        let source = "import re\nre.search(r'function (no)?watchplus()', webpage)\n";
4495        let facts = embedded_for_source(source);
4496        assert!(
4497            facts
4498                .iter()
4499                .any(|fact| fact.kind == NormalizedKind::RegexPattern)
4500        );
4501        for (index, fact) in facts.iter().enumerate() {
4502            if let Some(parent) = fact.parent {
4503                assert!(parent < index, "{fact:?}");
4504                let parent_fact = &facts[parent];
4505                assert!(
4506                    parent_fact.range.start_byte <= fact.range.start_byte
4507                        && fact.range.end_byte <= parent_fact.range.end_byte,
4508                    "child {fact:?} is outside parent {parent_fact:?}"
4509                );
4510            }
4511        }
4512        let closing_paren = source.find("()'").expect("trailing empty group") + 1;
4513        assert!(facts.iter().any(|fact| {
4514            fact.kind == NormalizedKind::RegexSequence
4515                && fact.range.start_byte == closing_paren
4516                && fact.range.end_byte == closing_paren
4517        }));
4518    }
4519}