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};
18use tree_sitter::Node;
19
20use brokk_bifrost_core::analyzer::structural::regex::{
21    CharacterSet, ClassAtom, EscapeValue, Fact, GroupPrefix, MAX_PATTERN_CHARS, MappedChar,
22    NfaError, RegexAnalysis, RegexFlags, RegexNfa, RegexParser, RegexSyntax, single_character,
23};
24
25#[derive(Debug)]
26struct DecodedValue {
27    value: String,
28    chars: Vec<MappedChar>,
29    source_range: Range,
30    is_bytes: bool,
31}
32
33#[derive(Debug, Clone, Copy)]
34struct Api {
35    flags_position: usize,
36    scans_from_each_offset: bool,
37}
38
39/// Return facts for a string-literal node or a call node in the Python AST.
40/// A string is analyzed only when it is the first positional argument or the
41/// `pattern` keyword of a statically named `re` API call.
42pub(crate) fn embedded_facts(node: Node<'_>, source: &str) -> Vec<EmbeddedLeafFact> {
43    if node.kind() == "call" {
44        return dynamic_pattern_fact(node, source).into_iter().collect();
45    }
46    if !matches!(node.kind(), "string" | "concatenated_string")
47        || (node.kind() == "string"
48            && node
49                .parent()
50                .is_some_and(|parent| parent.kind() == "concatenated_string"))
51    {
52        return Vec::new();
53    }
54    let Some((call, api)) = pattern_call(node, source) else {
55        return Vec::new();
56    };
57    let decoded = match decode_regex_pattern_string(node, source) {
58        Ok(decoded) => decoded,
59        Err(reason) => return vec![incomplete_fact(node, source, reason)],
60    };
61    let flags = call_flags(call, api, source);
62    let facts = parse_pattern(&decoded, flags, api);
63    facts_to_embedded(facts, &decoded, source)
64}
65
66pub(crate) fn module_assigned_facts(root: Node<'_>, source: &str) -> Vec<EmbeddedLeafFact> {
67    assert_eq!(root.kind(), "module");
68    let mut assignments = HashMap::<String, (usize, Option<Node<'_>>)>::new();
69    let mut calls = Vec::new();
70    let mut pending = vec![root];
71    while let Some(node) = pending.pop() {
72        if module_level_statement(node)
73            .is_some_and(|statement| statement.kind() == "expression_statement")
74            && node.kind() == "assignment"
75            && let (Some(left), Some(right)) = (
76                node.child_by_field_name("left"),
77                node.child_by_field_name("right"),
78            )
79            && left.kind() == "identifier"
80        {
81            let candidate =
82                matches!(right.kind(), "string" | "concatenated_string").then_some(right);
83            let entry = assignments
84                .entry(node_text(left, source).to_owned())
85                .or_insert((0, None));
86            entry.0 += 1;
87            entry.1 = (entry.0 == 1).then_some(candidate).flatten();
88        }
89        if node.kind() == "call"
90            && let Some(function) = node.child_by_field_name("function")
91            && let Some(api) = recognized_api(function, source)
92            && let Some(arguments) = node.child_by_field_name("arguments")
93            && let Some(pattern) = selected_pattern_argument(arguments, source)
94            && pattern.kind() == "identifier"
95        {
96            calls.push((node, pattern, api, node_text(pattern, source).to_owned()));
97        }
98        let mut cursor = node.walk();
99        let children = node.named_children(&mut cursor).collect::<Vec<_>>();
100        pending.extend(children.into_iter().rev());
101    }
102
103    let mut facts = Vec::new();
104    for (call, pattern, api, name) in calls {
105        let resolved = assignments
106            .get(&name)
107            .and_then(|(writes, candidate)| {
108                (*writes == 1)
109                    .then_some(*candidate)
110                    .flatten()
111                    .filter(|candidate| candidate.end_byte() < call.start_byte())
112            })
113            .filter(|_| module_pattern_binding_is_visible(&name, pattern, source));
114        let Some(selected) = resolved else {
115            let range = node_range(pattern, source);
116            facts.push(EmbeddedLeafFact {
117                kind: NormalizedKind::RegexIncomplete,
118                range,
119                parent: None,
120                construct: Some(format!("dynamic-pattern:{}", api.flags_position)),
121                occurrence_role: None,
122            });
123            continue;
124        };
125        let decoded = match decode_regex_pattern_string(selected, source) {
126            Ok(decoded) => decoded,
127            Err(reason) => {
128                facts.push(incomplete_fact(selected, source, reason));
129                continue;
130            }
131        };
132        let flags = call_flags(call, api, source);
133        let mut parsed = facts_to_embedded(parse_pattern(&decoded, flags, api), &decoded, source);
134        let base = facts.len();
135        for fact in &mut parsed {
136            fact.parent = fact.parent.map(|parent| base + parent);
137        }
138        facts.extend(parsed);
139    }
140    facts
141}
142
143fn module_pattern_binding_is_visible(name: &str, reference: Node<'_>, source: &str) -> bool {
144    if python_comprehension_binds_name_at(name, reference, source)
145        || matches!(
146            python_name_resolution_at(name, reference, source),
147            PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
148        )
149    {
150        return false;
151    }
152
153    let mut current = reference;
154    while let Some(parent) = current.parent() {
155        match parent.kind() {
156            "function_definition" | "lambda" => break,
157            "class_definition" => {
158                return python_module_or_class_scope_binds_name_bounded(
159                    parent,
160                    name,
161                    source,
162                    || true,
163                ) == Some(false);
164            }
165            _ => current = parent,
166        }
167    }
168    true
169}
170
171fn module_level_statement<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
172    let mut current = node;
173    loop {
174        let parent = current.parent()?;
175        if parent.kind() == "module" {
176            return Some(current);
177        }
178        current = parent;
179    }
180}
181
182fn incomplete_fact(node: Node<'_>, source: &str, reason: &str) -> EmbeddedLeafFact {
183    let range = node_range(node, source);
184    EmbeddedLeafFact {
185        kind: NormalizedKind::RegexIncomplete,
186        range,
187        parent: None,
188        construct: Some(reason.to_owned()),
189        occurrence_role: None,
190    }
191}
192
193fn dynamic_pattern_fact(node: Node<'_>, source: &str) -> Option<EmbeddedLeafFact> {
194    let function = node.child_by_field_name("function")?;
195    let api = recognized_api(function, source)?;
196    let arguments = node.child_by_field_name("arguments")?;
197    let pattern = selected_pattern_argument(arguments, source)?;
198    if matches!(pattern.kind(), "string" | "concatenated_string") {
199        return None;
200    }
201    if pattern.kind() == "identifier" {
202        return None;
203    }
204    let range = node_range(pattern, source);
205    Some(EmbeddedLeafFact {
206        kind: NormalizedKind::RegexIncomplete,
207        range,
208        parent: None,
209        construct: Some(format!("dynamic-pattern:{}", api.flags_position)),
210        occurrence_role: None,
211    })
212}
213
214fn pattern_call<'tree>(node: Node<'tree>, source: &str) -> Option<(Node<'tree>, Api)> {
215    let argument = node.parent()?;
216    let (arguments, is_pattern_keyword) = match argument.kind() {
217        "keyword_argument" => {
218            let name = argument.child_by_field_name("name")?;
219            let value = argument.child_by_field_name("value")?;
220            (
221                argument.parent()?,
222                value.id() == node.id() && node_text(name, source) == "pattern",
223            )
224        }
225        "argument_list" => (argument, false),
226        _ => return None,
227    };
228    if arguments.kind() != "argument_list" {
229        return None;
230    }
231    let call = arguments.parent()?;
232    if call.kind() != "call" {
233        return None;
234    }
235    let function = call.child_by_field_name("function")?;
236    let api = recognized_api(function, source)?;
237    let selected = if is_pattern_keyword {
238        true
239    } else {
240        selected_pattern_argument(arguments, source)
241            .is_some_and(|pattern| pattern.id() == node.id())
242    };
243    selected.then_some((call, api))
244}
245
246fn selected_pattern_argument<'tree>(arguments: Node<'tree>, source: &str) -> Option<Node<'tree>> {
247    let mut positional = 0usize;
248    let mut cursor = arguments.walk();
249    for argument in arguments.named_children(&mut cursor) {
250        match argument.kind() {
251            "comment" => {}
252            "keyword_argument" => {
253                let name = argument.child_by_field_name("name")?;
254                if node_text(name, source) == "pattern" {
255                    return argument.child_by_field_name("value");
256                }
257            }
258            _ => {
259                if positional == 0 {
260                    return Some(argument);
261                }
262                positional += 1;
263            }
264        }
265    }
266    None
267}
268
269fn recognized_api(function: Node<'_>, source: &str) -> Option<Api> {
270    let path = python_static_attribute_path(function)?;
271    if path.len() != 2 || node_text(path[0], source) != "re" {
272        return None;
273    }
274    let (flags_position, scans_from_each_offset) = match node_text(path[1], source) {
275        "compile" => (1, false),
276        "search" | "findall" | "finditer" => (2, true),
277        "match" | "fullmatch" => (2, false),
278        "split" => (3, true),
279        "sub" | "subn" => (4, true),
280        _ => return None,
281    };
282    Some(Api {
283        flags_position,
284        scans_from_each_offset,
285    })
286}
287
288fn call_flags(call: Node<'_>, api: Api, source: &str) -> RegexFlags {
289    let mut flags_node = None;
290    if let Some(arguments) = call.child_by_field_name("arguments") {
291        let mut positional = 0usize;
292        let mut cursor = arguments.walk();
293        for argument in arguments.named_children(&mut cursor) {
294            match argument.kind() {
295                "comment" => {}
296                "keyword_argument" => {
297                    if argument
298                        .child_by_field_name("name")
299                        .is_some_and(|name| node_text(name, source) == "flags")
300                    {
301                        flags_node = argument.child_by_field_name("value");
302                    }
303                }
304                _ => {
305                    if positional == api.flags_position {
306                        flags_node = Some(argument);
307                    }
308                    positional += 1;
309                }
310            }
311        }
312    }
313    let Some(flags_node) = flags_node else {
314        return RegexFlags {
315            dialect_flags: 0,
316            dotall: false,
317            multiline: false,
318            ignorecase: false,
319            ascii: false,
320            verbose: false,
321            complete: true,
322        };
323    };
324    let mut pending = vec![flags_node];
325    let mut flags = RegexFlags {
326        dialect_flags: 0,
327        dotall: false,
328        multiline: false,
329        ignorecase: false,
330        ascii: false,
331        verbose: false,
332        complete: true,
333    };
334    while let Some(node) = pending.pop() {
335        match node.kind() {
336            "integer" => {
337                let text = node_text(node, source).replace('_', "");
338                match python_integer_value(&text) {
339                    Some(value) => {
340                        flags.ignorecase |= value & 2 != 0;
341                        flags.dotall |= value & 16 != 0;
342                        flags.multiline |= value & 8 != 0;
343                        flags.ascii |= value & 256 != 0;
344                        flags.complete &=
345                            value & !(2 | 8 | 16 | 32 | 256) == 0 && value & (64 | 128) == 0;
346                    }
347                    None => flags.complete = false,
348                }
349            }
350            "attribute" | "identifier" => {
351                let path = python_static_attribute_path(node);
352                let name = path.as_ref().and_then(|path| {
353                    (path.len() >= 2
354                        && node_text(path[0], source) == "re"
355                        && path[1..path.len() - 1]
356                            .iter()
357                            .all(|part| node_text(*part, source) == "re"))
358                    .then(|| node_text(*path.last().expect("flag path is nonempty"), source))
359                });
360                match name {
361                    Some("S" | "DOTALL") => flags.dotall = true,
362                    Some("M" | "MULTILINE") => flags.multiline = true,
363                    Some("I" | "IGNORECASE") => flags.ignorecase = true,
364                    Some("A" | "ASCII") => flags.ascii = true,
365                    Some("X" | "VERBOSE") => flags.verbose = true,
366                    Some("U" | "UNICODE") => {}
367                    Some("DEBUG") => flags.complete = false,
368                    _ => flags.complete = false,
369                }
370            }
371            "binary_operator" => {
372                if let (Some(left), Some(right)) = (
373                    node.child_by_field_name("left"),
374                    node.child_by_field_name("right"),
375                ) {
376                    pending.push(right);
377                    pending.push(left);
378                } else {
379                    flags.complete = false;
380                }
381            }
382            _ => flags.complete = false,
383        }
384    }
385    flags
386}
387
388fn parse_inline_flags(
389    chars: &[char],
390    mut index: usize,
391    mut flags: RegexFlags,
392) -> Option<(usize, RegexFlags)> {
393    let mut enabled = true;
394    let mut saw_flag = false;
395    let mut ascii_mode = None;
396    while let Some(character) = chars.get(index).copied() {
397        match character {
398            ')' | ':' if saw_flag && (enabled || character == ':') => {
399                if ascii_mode == Some((true, true)) {
400                    return None;
401                }
402                return Some((index, flags));
403            }
404            '-' if enabled => {
405                enabled = false;
406                saw_flag = false;
407            }
408            'i' | 'm' | 's' | 'x' | 'a' | 'u' => {
409                if !enabled && matches!(character, 'a' | 'u') {
410                    return None;
411                }
412                saw_flag = true;
413                match character {
414                    'i' => flags.ignorecase = enabled,
415                    'm' => flags.multiline = enabled,
416                    's' => flags.dotall = enabled,
417                    'x' => flags.verbose = enabled,
418                    'a' => {
419                        flags.ascii = enabled;
420                        ascii_mode =
421                            Some((enabled, ascii_mode.is_some_and(|(_, unicode)| unicode)));
422                    }
423                    'u' => {
424                        flags.ascii = !enabled;
425                        ascii_mode = Some((ascii_mode.is_some_and(|(ascii, _)| ascii), enabled));
426                    }
427                    _ => unreachable!("inline flag arm includes only Python regex flags"),
428                }
429            }
430            _ => return None,
431        }
432        index += 1;
433    }
434    None
435}
436
437fn python_integer_value(text: &str) -> Option<u64> {
438    let (radix, digits) =
439        if let Some(digits) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
440            (16, digits)
441        } else if let Some(digits) = text.strip_prefix("0o").or_else(|| text.strip_prefix("0O")) {
442            (8, digits)
443        } else if let Some(digits) = text.strip_prefix("0b").or_else(|| text.strip_prefix("0B")) {
444            (2, digits)
445        } else {
446            (10, text)
447        };
448    u64::from_str_radix(digits, radix).ok()
449}
450
451fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
452    node.utf8_text(source.as_bytes())
453        .expect("tree-sitter node ranges are valid UTF-8 source")
454}
455
456fn node_range(node: Node<'_>, source: &str) -> Range {
457    let line_starts = compute_line_starts(source);
458    let start_byte = node.start_byte();
459    let end_byte = node.end_byte();
460    Range {
461        start_byte,
462        end_byte,
463        start_line: find_line_index_for_offset(&line_starts, start_byte) + 1,
464        end_line: find_line_index_for_offset(
465            &line_starts,
466            end_byte.saturating_sub(1).max(start_byte),
467        ) + 1,
468    }
469}
470
471fn decode_python_string(node: Node<'_>, source: &str) -> Result<DecodedValue, &'static str> {
472    let decoded = decode_string_literal(node, source)?;
473    if decoded.is_bytes {
474        return Err("non-text-or-formatted-string");
475    }
476    Ok(decoded)
477}
478
479fn decode_regex_pattern_string(node: Node<'_>, source: &str) -> Result<DecodedValue, &'static str> {
480    decode_string_literal(node, source)
481}
482
483fn decode_string_literal(node: Node<'_>, source: &str) -> Result<DecodedValue, &'static str> {
484    let mut strings = Vec::new();
485    if node.kind() == "string" {
486        strings.push(node);
487    } else {
488        let mut stack = vec![node];
489        while let Some(current) = stack.pop() {
490            if current.kind() == "string" {
491                strings.push(current);
492                continue;
493            }
494            let mut children = Vec::new();
495            let mut cursor = current.walk();
496            for child in current.named_children(&mut cursor) {
497                if child.kind() == "concatenated_string" || child.kind() == "string" {
498                    children.push(child);
499                } else {
500                    return Err("non-literal-string-segment");
501                }
502            }
503            for child in children.into_iter().rev() {
504                stack.push(child);
505            }
506        }
507    }
508    if strings.is_empty() {
509        return Err("empty-concatenated-string");
510    }
511    let mut value = String::new();
512    let mut chars = Vec::new();
513    let mut byte_string = None;
514    for string in strings {
515        let is_bytes = decode_string_segment(string, source, &mut value, &mut chars)?;
516        if byte_string.is_some_and(|previous| previous != is_bytes) {
517            return Err("mixed-bytes-and-text-concatenation");
518        }
519        byte_string = Some(is_bytes);
520        if chars.len() > MAX_PATTERN_CHARS {
521            return Err("pattern-character-budget");
522        }
523    }
524    Ok(DecodedValue {
525        value,
526        chars,
527        source_range: node_range(node, source),
528        is_bytes: byte_string.expect("non-empty literal expression has a string segment"),
529    })
530}
531
532/// Decode the value of a tree-sitter-selected Python string expression.
533///
534/// Callers must select the literal from syntax before invoking this helper;
535/// this function classifies only the selected expression's value.
536pub fn decode_python_string_value(node: Node<'_>, source: &str) -> Result<String, &'static str> {
537    decode_python_string(node, source).map(|decoded| decoded.value)
538}
539
540/// Decode each literal segment in a selected Python f-string, omitting its
541/// interpolation expressions. The parse tree chooses the segments; this
542/// helper reads only their decoded values.
543pub fn decode_python_fstring_literal_segments(
544    node: Node<'_>,
545    source: &str,
546) -> Result<Vec<String>, &'static str> {
547    if node.kind() != "string" {
548        return Err("not-a-python-fstring");
549    }
550    let mut start_token = None;
551    let mut segments: Vec<Vec<Node<'_>>> = vec![Vec::new()];
552    let mut cursor = node.walk();
553    for child in node.named_children(&mut cursor) {
554        match child.kind() {
555            "string_start" if start_token.is_none() => start_token = Some(child),
556            "string_start" => return Err("duplicate-fstring-start"),
557            "string_end" => break,
558            "interpolation" => segments.push(Vec::new()),
559            "string_content" | "escape_sequence" => segments
560                .last_mut()
561                .expect("f-string literal segments retain a current segment")
562                .push(child),
563            _ => return Err("unsupported-fstring-prefix-node"),
564        }
565    }
566    let start_token = start_token.ok_or("missing-fstring-start")?;
567    let prefix = string_prefix(node_text(start_token, source));
568    if !prefix.contains('f')
569        || prefix
570            .chars()
571            .any(|character| !matches!(character, 'f' | 'r'))
572        || prefix.matches('f').count() != 1
573        || prefix.matches('r').count() > 1
574    {
575        return Err("unsupported-fstring-prefix");
576    }
577    let raw = prefix.contains('r');
578    segments
579        .into_iter()
580        .map(|parts| {
581            let mut value = String::new();
582            let mut mapped = Vec::new();
583            for part in parts {
584                if raw {
585                    append_mapped_text(
586                        node_text(part, source),
587                        part.start_byte(),
588                        part.end_byte(),
589                        &mut value,
590                        &mut mapped,
591                    )?;
592                } else if part.kind() == "escape_sequence" {
593                    decode_literal_escape(
594                        node_text(part, source),
595                        part.start_byte(),
596                        &mut value,
597                        &mut mapped,
598                        false,
599                    )?;
600                } else {
601                    decode_string_content(part, source, &mut value, &mut mapped, false)?;
602                }
603            }
604            Ok(value.replace("{{", "{").replace("}}", "}"))
605        })
606        .collect()
607}
608
609fn decode_string_segment(
610    node: Node<'_>,
611    source: &str,
612    value: &mut String,
613    output: &mut Vec<MappedChar>,
614) -> Result<bool, &'static str> {
615    let mut start_token = None;
616    let mut body = Vec::new();
617    let mut cursor = node.walk();
618    for child in node.named_children(&mut cursor) {
619        match child.kind() {
620            "string_start" => start_token = Some(child),
621            "string_end" => {}
622            "string_content" | "escape_sequence" => body.push(child),
623            "interpolation" => return Err("formatted-string-interpolation"),
624            _ => return Err("unsupported-string-segment"),
625        }
626    }
627    let start_token = start_token.ok_or("missing-string-start")?;
628    let prefix = string_prefix(node_text(start_token, source));
629    if prefix.contains('f') || prefix.contains('t') {
630        return Err("non-text-or-formatted-string");
631    }
632    if prefix
633        .chars()
634        .any(|prefix| !matches!(prefix, 'r' | 'u' | 'b'))
635        || prefix.matches('r').count() > 1
636        || prefix.matches('u').count() > 1
637        || prefix.matches('b').count() > 1
638        || (prefix.contains('r') && prefix.contains('u'))
639        || (prefix.contains('b') && prefix.contains('u'))
640    {
641        return Err("unsupported-string-prefix");
642    }
643    let is_bytes = prefix.contains('b');
644    if is_bytes && !node_text(node, source).is_ascii() {
645        return Err("non-ascii-byte-literal");
646    }
647    let raw = prefix.contains('r');
648    for part in body {
649        let raw_text = node_text(part, source);
650        if raw && matches!(part.kind(), "escape_sequence" | "string_content") {
651            append_mapped_text(raw_text, part.start_byte(), part.end_byte(), value, output)?;
652        } else if !raw && part.kind() == "escape_sequence" {
653            decode_literal_escape(raw_text, part.start_byte(), value, output, is_bytes)?;
654        } else if !raw && part.kind() == "string_content" {
655            decode_string_content(part, source, value, output, is_bytes)?;
656        } else {
657            append_mapped_text(raw_text, part.start_byte(), part.end_byte(), value, output)?;
658        }
659        if output.len() > MAX_PATTERN_CHARS {
660            return Err("pattern-character-budget");
661        }
662    }
663    if is_bytes && value.chars().any(|character| character as u32 > 0xff) {
664        return Err("byte-pattern-value-out-of-range");
665    }
666    Ok(is_bytes)
667}
668
669fn string_prefix(start: &str) -> String {
670    start
671        .chars()
672        .take_while(|character| !matches!(character, '\'' | '"'))
673        .map(|character| character.to_ascii_lowercase())
674        .collect()
675}
676
677fn append_mapped_text(
678    text: &str,
679    source_start: usize,
680    source_end: usize,
681    output_text: &mut String,
682    output: &mut Vec<MappedChar>,
683) -> Result<(), &'static str> {
684    for (offset, value) in text.char_indices() {
685        if output.len() >= MAX_PATTERN_CHARS {
686            return Err("pattern-character-budget");
687        }
688        let start = source_start + offset;
689        let end = start + value.len_utf8();
690        output_text.push(value);
691        output.push(MappedChar {
692            source_start: start,
693            source_end: end,
694        });
695    }
696    debug_assert_eq!(source_start + text.len(), source_end);
697    Ok(())
698}
699
700fn decode_string_content(
701    node: Node<'_>,
702    source: &str,
703    output_text: &mut String,
704    output: &mut Vec<MappedChar>,
705    bytes: bool,
706) -> Result<(), &'static str> {
707    let mut position = node.start_byte();
708    let mut cursor = node.walk();
709    for child in node.named_children(&mut cursor) {
710        assert_eq!(child.kind(), "escape_sequence");
711        assert!(position <= child.start_byte());
712        if position < child.start_byte() {
713            let plain = source
714                .get(position..child.start_byte())
715                .expect("tree-sitter string ranges are valid UTF-8 source");
716            append_mapped_text(plain, position, child.start_byte(), output_text, output)?;
717        }
718        decode_literal_escape(
719            node_text(child, source),
720            child.start_byte(),
721            output_text,
722            output,
723            bytes,
724        )?;
725        position = child.end_byte();
726    }
727    assert!(position <= node.end_byte());
728    if position < node.end_byte() {
729        let plain = source
730            .get(position..node.end_byte())
731            .expect("tree-sitter string ranges are valid UTF-8 source");
732        append_mapped_text(plain, position, node.end_byte(), output_text, output)?;
733    }
734    Ok(())
735}
736
737fn decode_literal_escape(
738    raw: &str,
739    source_start: usize,
740    output_text: &mut String,
741    output: &mut Vec<MappedChar>,
742    bytes: bool,
743) -> Result<(), &'static str> {
744    let chars: Vec<char> = raw.chars().collect();
745    if chars.first() != Some(&'\\') || chars.len() < 2 {
746        return Err("malformed-literal-escape");
747    }
748    if bytes && matches!(chars.get(1), Some('u' | 'U' | 'N')) {
749        return append_mapped_text(
750            raw,
751            source_start,
752            source_start + raw.len(),
753            output_text,
754            output,
755        );
756    }
757    let escaped = chars[1];
758    let replacement = match escaped {
759        '\n' => None,
760        '\r' if chars.get(2) == Some(&'\n') => None,
761        '\r' => Some('\r'),
762        '\\' => Some('\\'),
763        '\'' => Some('\''),
764        '"' => Some('"'),
765        'a' => Some('\u{7}'),
766        'b' => Some('\u{8}'),
767        'f' => Some('\u{c}'),
768        'n' => Some('\n'),
769        'r' => Some('\r'),
770        't' => Some('\t'),
771        'v' => Some('\u{b}'),
772        'x' => Some(
773            char::from_u32(hex_value(&chars, 2, 2).ok_or("invalid-hex-escape")?)
774                .ok_or("invalid-hex-scalar")?,
775        ),
776        'u' => Some(
777            char::from_u32(hex_value(&chars, 2, 4).ok_or("invalid-unicode-escape")?)
778                .ok_or("invalid-unicode-scalar")?,
779        ),
780        'U' => Some(
781            char::from_u32(hex_value(&chars, 2, 8).ok_or("invalid-unicode-escape")?)
782                .ok_or("invalid-unicode-scalar")?,
783        ),
784        'N' => return Err("named-unicode-escape"),
785        '0'..='7' => {
786            let mut value = (escaped as u32) - ('0' as u32);
787            let mut digits = 1usize;
788            while digits < 3 {
789                let Some(digit @ '0'..='7') = chars.get(1 + digits).copied() else {
790                    break;
791                };
792                value = value * 8 + (digit as u32 - '0' as u32);
793                digits += 1;
794            }
795            Some(char::from_u32(value).ok_or("invalid-octal-scalar")?)
796        }
797        // Python preserves unrecognized escapes, including punctuation; the
798        // regex parser must receive the backslash to classify them correctly.
799        other => {
800            if output.len().saturating_add(2) > MAX_PATTERN_CHARS {
801                return Err("pattern-character-budget");
802            }
803            output_text.push('\\');
804            output.push(MappedChar {
805                source_start,
806                source_end: source_start + 1,
807            });
808            output_text.push(other);
809            output.push(MappedChar {
810                source_start: source_start + 1,
811                source_end: source_start + 1 + other.len_utf8(),
812            });
813            return Ok(());
814        }
815    };
816    if let Some(replacement) = replacement {
817        if output.len() >= MAX_PATTERN_CHARS {
818            return Err("pattern-character-budget");
819        }
820        output_text.push(replacement);
821        output.push(MappedChar {
822            source_start,
823            source_end: source_start + raw.len(),
824        });
825    }
826    Ok(())
827}
828
829fn hex_value(chars: &[char], start: usize, digits: usize) -> Option<u32> {
830    chars
831        .get(start..start + digits)?
832        .iter()
833        .try_fold(0u32, |value, digit| {
834            digit.to_digit(16).map(|digit| value * 16 + digit)
835        })
836}
837
838/// Python's existing bounded regex-value grammar and Rust-regex bad-tag analysis.
839struct PythonRegexSyntax;
840
841impl RegexSyntax for PythonRegexSyntax {
842    fn group_prefix(&self, chars: &[char], start: usize) -> GroupPrefix {
843        match (chars.get(start + 1), chars.get(start + 2)) {
844            (Some(':'), _) => GroupPrefix::Noncapturing,
845            (Some('P'), Some('<')) => GroupPrefix::Named { name_offset: 3 },
846            (Some('#'), _) => GroupPrefix::Comment,
847            (Some('='), _) => GroupPrefix::LookaheadPositive,
848            (Some('!'), _) => GroupPrefix::LookaheadNegative,
849            (Some('<'), Some('=')) => GroupPrefix::LookbehindPositive,
850            (Some('<'), Some('!')) => GroupPrefix::LookbehindNegative,
851            (Some('i' | 'm' | 's' | 'x' | 'a' | 'u' | '-'), _) => GroupPrefix::Flags,
852            _ => GroupPrefix::Unsupported,
853        }
854    }
855    fn incomplete_group_end(&self, chars: &[char], start: usize) -> Option<usize> {
856        if chars.get(start) != Some(&'P') || chars.get(start + 1) != Some(&'<') {
857            return None;
858        }
859        let mut close = start + 2;
860        while close < chars.len() && chars[close] != '>' {
861            close += 1;
862        }
863        let name = &chars[start + 2..close.min(chars.len())];
864        let valid_name = name
865            .first()
866            .is_some_and(|first| first.is_alphabetic() || *first == '_')
867            && name
868                .iter()
869                .all(|character| character.is_alphanumeric() || *character == '_');
870        if close < chars.len() && valid_name {
871            return Some(close + 1);
872        }
873        None
874    }
875    fn inline_flags(
876        &self,
877        chars: &[char],
878        start: usize,
879        flags: RegexFlags,
880    ) -> Option<(usize, RegexFlags)> {
881        parse_inline_flags(chars, start, flags)
882    }
883    fn escape(
884        &self,
885        chars: &[char],
886        start: usize,
887        in_class: bool,
888        flags: RegexFlags,
889    ) -> Option<EscapeValue> {
890        parse_regex_escape(chars, start, in_class, flags.ascii)
891    }
892    fn class_atom(&self, chars: &[char], start: usize, flags: RegexFlags) -> Option<ClassAtom> {
893        parse_class_atom(chars, start, flags.ascii)
894    }
895    fn wildcard(&self, flags: RegexFlags) -> CharacterSet {
896        if flags.dotall {
897            CharacterSet {
898                ranges: vec![(0, 0x10ffff)],
899            }
900        } else {
901            CharacterSet::any_except_newline()
902        }
903    }
904    fn fold_case(&self, set: CharacterSet, flags: RegexFlags) -> CharacterSet {
905        if flags.ignorecase {
906            set.with_ascii_case_variants()
907        } else {
908            set
909        }
910    }
911    fn case_folding_is_complete(&self, flags: RegexFlags) -> bool {
912        !flags.ignorecase || flags.ascii
913    }
914    fn verbose_whitespace(&self, character: char) -> bool {
915        matches!(character, ' ' | '\t' | '\n' | '\r' | '\x0c' | '\x0b')
916    }
917    fn verbose_comment_terminator(&self, character: char) -> bool {
918        character == '\n'
919    }
920    fn supports_possessive_quantifiers(&self) -> bool {
921        true
922    }
923    fn analyze(&self, context: &mut RegexAnalysis<'_>) {
924        if context.incomplete.is_some() || !context.flags.complete {
925            return;
926        }
927        let value = context.value;
928        if !(value.contains('<') && value.contains('>'))
929            && !["-->", "--!>", "<--", "<!--"]
930                .iter()
931                .any(|marker| value.contains(marker))
932        {
933            return;
934        }
935        let rust_pattern = value
936            .replace("\\/", "/")
937            .replace("\\\"", "\"")
938            .replace("\\'", "'");
939        let mut builder = regex::RegexBuilder::new(&rust_pattern);
940        builder
941            .case_insensitive(context.flags.ignorecase)
942            .multi_line(context.flags.multiline)
943            .dot_matches_new_line(context.flags.dotall)
944            .unicode(!context.flags.ascii)
945            .ignore_whitespace(context.flags.verbose);
946        let Ok(regex) = builder.build() else {
947            return;
948        };
949        let capture_nfa = match RegexNfa::compile(context.facts, 0, None) {
950            Ok(nfa) => Some(nfa),
951            Err(error) => {
952                context.mark_incomplete(
953                    0,
954                    context.chars.len(),
955                    match error {
956                        NfaError::Unsupported => "bad-tag-nfa-unsupported",
957                        NfaError::Budget => "bad-tag-nfa-state-budget",
958                    },
959                );
960                return;
961            }
962        };
963        let matches = |sample: &str| {
964            capture_nfa.as_ref().map_or_else(
965                || regex.is_match(sample),
966                |nfa| nfa.match_prefix_capture_groups(sample).0,
967            )
968        };
969        let groups = |sample: &str| {
970            capture_nfa.as_ref().map_or_else(
971                || {
972                    regex
973                        .captures(sample)
974                        .map(|captures| {
975                            captures
976                                .iter()
977                                .enumerate()
978                                .skip(1)
979                                .filter_map(|(index, capture)| capture.map(|_| index))
980                                .collect::<HashSet<_>>()
981                        })
982                        .unwrap_or_default()
983                },
984                |nfa| nfa.match_prefix_capture_groups(sample).1,
985            )
986        };
987        let comment = "<!-- foo -->";
988        let comment_bang = "<!-- foo --!>";
989        let comment_groups = groups(comment);
990        let comment_bang_groups = groups(comment_bang);
991        let comment_group_mismatch = matches(comment)
992            && matches(comment_bang)
993            && comment_groups.iter().any(|group| {
994                !comment_bang_groups.contains(group)
995                    && comment_bang_groups.iter().any(|other| group != other)
996            });
997        let plain_tag_groups = groups("<foo>");
998        let only_comment_group = groups(comment).iter().any(|group| {
999            plain_tag_groups.iter().any(|other| other != group)
1000                && !matches(comment_bang)
1001                && !groups("<!- foo ->").contains(group)
1002                && !plain_tag_groups.contains(group)
1003                && !groups("<script>").contains(group)
1004        });
1005        let bounded_script_opener = value
1006            .to_ascii_lowercase()
1007            .find("<script")
1008            .map(|start| {
1009                let start = value[..start].chars().count() + "<script".len();
1010                context
1011                    .facts
1012                    .iter()
1013                    .filter(|fact| {
1014                        fact.kind == NormalizedKind::RegexLiteral
1015                            && fact.construct.as_deref() == Some(">")
1016                            && fact.start >= start
1017                            && !fact.parent.is_some_and(|parent| {
1018                                context.facts[parent].kind == NormalizedKind::RegexCharacterClass
1019                            })
1020                    })
1021                    .map(|fact| fact.start)
1022                    .min()
1023                    .is_some_and(|tag_end| {
1024                        context.facts.iter().enumerate().any(|(id, fact)| {
1025                            fact.kind == NormalizedKind::RegexCharacterClass
1026                                && fact.start >= start
1027                                && fact.end <= tag_end
1028                                && fact
1029                                    .first_set
1030                                    .as_ref()
1031                                    .is_some_and(|set| !set.contains('>'))
1032                                && context.facts.iter().any(|quantifier| {
1033                                    quantifier.kind == NormalizedKind::RegexQuantifier
1034                                        && quantifier.parent == Some(id)
1035                                        && quantifier
1036                                            .quantifier
1037                                            .is_some_and(|quantifier| quantifier.maximum.is_none())
1038                                })
1039                        })
1040                    })
1041            })
1042            .unwrap_or(false);
1043        let script_warning = !bounded_script_opener
1044            && ((matches("<script>foo</script>")
1045                && matches("<script src=\"foo\"></script>")
1046                && !matches("<foo ></foo>")
1047                && (!matches("<script \n>foo</script>") || !matches("<script >foo\n</script>")))
1048                || (matches("<script>foo</script>")
1049                    && matches("<script src=\"foo\"></script>")
1050                    && !matches("<script src='foo'></script>")
1051                    && !matches("<foo>"))
1052                || (matches("<script>foo</script>")
1053                    && matches("<script src='foo'></script>")
1054                    && !matches("<script src=\"foo\"></script>")
1055                    && !matches("<foo>"))
1056                || (matches("<script>foo</script>")
1057                    && matches("<script src='foo'></script>")
1058                    && !matches("<script\tsrc='foo'></script>")
1059                    && !matches("<foo>")
1060                    && !matches("<foo src=\"foo\"></foo>"))
1061                || (matches("<script>foo</script>")
1062                    && !context.flags.ignorecase
1063                    && !matches("<foo>")
1064                    && !matches("<foo ></foo>")
1065                    && (!matches("<SCRIPT>foo</SCRIPT>")
1066                        || (matches("<SCRIPT>foo</SCRIPT>") && !matches("<sCrIpT>foo</ScRiPt>"))))
1067                || (matches("<script src=\"foo\"></script>")
1068                    && !matches("<foo>")
1069                    && !matches("<foo ></foo>")
1070                    && (!matches("<script src=\"foo\">foo</script >")
1071                        || !matches("<script src=\"foo\">foo</script foo=\"bar\">")
1072                        || !matches("<script src=\"foo\">foo</script\t\n bar>"))));
1073        let warning = comment_group_mismatch
1074            || only_comment_group
1075            || (matches("-->") && !matches("--!>") && !matches("--"))
1076            || (matches(comment)
1077                && !matches("<!-- foo\n -->")
1078                && !matches("<!- foo ->")
1079                && !matches("<foo>")
1080                && !matches("<script>"))
1081            || script_warning;
1082        if warning {
1083            context.pending_risks.push((
1084                NormalizedKind::RegexRiskBadTag,
1085                0,
1086                0,
1087                context.chars.len(),
1088                "matches-html-samples-inconsistently".to_owned(),
1089            ));
1090        }
1091    }
1092}
1093
1094fn category_character_set(escaped: char, ascii: bool) -> Option<CharacterSet> {
1095    let (category, negated) = match escaped {
1096        'd' => ('d', false),
1097        'D' => ('d', true),
1098        's' => ('s', false),
1099        'S' => ('s', true),
1100        'w' => ('w', false),
1101        'W' => ('w', true),
1102        _ => return None,
1103    };
1104    let mut set = match category {
1105        'd' => CharacterSet {
1106            ranges: vec![(b'0' as u32, b'9' as u32)],
1107        },
1108        's' => CharacterSet {
1109            ranges: vec![(9, 13), (32, 32)],
1110        },
1111        'w' => CharacterSet {
1112            ranges: vec![
1113                (b'0' as u32, b'9' as u32),
1114                (b'A' as u32, b'Z' as u32),
1115                (b'_' as u32, b'_' as u32),
1116                (b'a' as u32, b'z' as u32),
1117            ],
1118        },
1119        _ => unreachable!("category is selected from d, s, w"),
1120    };
1121    if !ascii && negated {
1122        // A Unicode negated category is not the complement of its ASCII
1123        // subset. Leave it unknown rather than claim a false overlap.
1124    }
1125    if !ascii && negated {
1126        return None;
1127    }
1128    if negated {
1129        set = set.complement();
1130    }
1131    Some(set)
1132}
1133
1134fn parse_regex_escape(
1135    chars: &[char],
1136    start: usize,
1137    in_class: bool,
1138    ascii: bool,
1139) -> Option<EscapeValue> {
1140    if chars.get(start) != Some(&'\\') {
1141        return None;
1142    }
1143    let escaped = *chars.get(start + 1)?;
1144    let mut end = start + 2;
1145    let mut value = None;
1146    let mut anchor = false;
1147    let mut incomplete = None;
1148    let mut backspace = false;
1149    let construct = match escaped {
1150        'A' | 'Z' | 'z' | 'b' | 'B' if !in_class => {
1151            anchor = true;
1152            Some(format!("boundary:{escaped}"))
1153        }
1154        'd' | 'D' | 's' | 'S' | 'w' | 'W' => Some(format!("category:{escaped}")),
1155        'a' => {
1156            value = Some('\u{7}');
1157            Some("U+0007".to_owned())
1158        }
1159        'f' => {
1160            value = Some('\u{c}');
1161            Some("U+000C".to_owned())
1162        }
1163        'n' => {
1164            value = Some('\n');
1165            Some("U+000A".to_owned())
1166        }
1167        'r' => {
1168            value = Some('\r');
1169            Some("U+000D".to_owned())
1170        }
1171        't' => {
1172            value = Some('\t');
1173            Some("U+0009".to_owned())
1174        }
1175        'v' => {
1176            value = Some('\u{b}');
1177            Some("U+000B".to_owned())
1178        }
1179        'b' if in_class => {
1180            value = Some('\u{8}');
1181            backspace = true;
1182            Some("backspace".to_owned())
1183        }
1184        'x' | 'u' | 'U' => {
1185            let digits = match escaped {
1186                'x' => 2,
1187                'u' => 4,
1188                _ => 8,
1189            };
1190            let scalar = hex_value(chars, start + 2, digits);
1191            end = start + 2 + digits;
1192            match scalar.and_then(char::from_u32) {
1193                Some(character) => {
1194                    value = Some(character);
1195                    Some(format!("U+{:04X}", character as u32))
1196                }
1197                None => {
1198                    incomplete = Some("invalid-hex-escape");
1199                    Some(format!("invalid:{escaped}"))
1200                }
1201            }
1202        }
1203        'N' => {
1204            incomplete = Some("named-unicode-escape");
1205            if chars.get(start + 2) == Some(&'{') {
1206                let mut close = start + 3;
1207                while close < chars.len() && chars[close] != '}' {
1208                    close += 1;
1209                }
1210                end = (close + 1).min(chars.len());
1211            }
1212            Some("unicode-name".to_owned())
1213        }
1214        '0'..='7' => {
1215            let mut codepoint = escaped as u32 - '0' as u32;
1216            let mut digits = 1usize;
1217            while digits < 3 {
1218                let Some(digit @ '0'..='7') = chars.get(start + 1 + digits).copied() else {
1219                    break;
1220                };
1221                codepoint = codepoint * 8 + (digit as u32 - '0' as u32);
1222                digits += 1;
1223            }
1224            end = start + 1 + digits;
1225            match char::from_u32(codepoint) {
1226                Some(character) => {
1227                    value = Some(character);
1228                    Some(format!("U+{codepoint:04X}"))
1229                }
1230                None => {
1231                    incomplete = Some("invalid-octal-escape");
1232                    Some("invalid-octal".to_owned())
1233                }
1234            }
1235        }
1236        '1'..='9' if !in_class => {
1237            incomplete = Some("backreference-not-supported");
1238            Some(format!("backreference:{escaped}"))
1239        }
1240        'g' | 'k' if !in_class => {
1241            incomplete = Some("named-backreference-not-supported");
1242            Some(format!("backreference:{escaped}"))
1243        }
1244        other if other.is_ascii_alphabetic() => {
1245            incomplete = Some("unknown-regex-escape");
1246            Some(format!("unknown:{other}"))
1247        }
1248        other => {
1249            value = Some(other);
1250            Some(other.to_string())
1251        }
1252    };
1253    if end > chars.len() {
1254        return None;
1255    }
1256    let first_set = value
1257        .map(CharacterSet::one)
1258        .or_else(|| category_character_set(escaped, ascii));
1259    Some(EscapeValue {
1260        end,
1261        anchor,
1262        construct,
1263        first_set,
1264        incomplete,
1265        backspace,
1266    })
1267}
1268
1269fn parse_class_atom(chars: &[char], start: usize, ascii: bool) -> Option<ClassAtom> {
1270    let value = *chars.get(start)?;
1271    if value != '\\' {
1272        return Some(ClassAtom {
1273            start,
1274            end: start + 1,
1275            value: Some(value),
1276            set: Some(CharacterSet::one(value)),
1277            kind: NormalizedKind::RegexLiteral,
1278            construct: Some(value.to_string()),
1279            backspace: false,
1280            incomplete: None,
1281        });
1282    }
1283    let escape = parse_regex_escape(chars, start, true, ascii)?;
1284    Some(ClassAtom {
1285        start,
1286        end: escape.end,
1287        value: escape.first_set.as_ref().and_then(single_character),
1288        set: escape.first_set,
1289        kind: NormalizedKind::RegexEscape,
1290        construct: escape.construct,
1291        backspace: escape.backspace,
1292        incomplete: escape.incomplete,
1293    })
1294}
1295
1296fn facts_to_embedded(
1297    facts: Vec<Fact>,
1298    decoded: &DecodedValue,
1299    source: &str,
1300) -> Vec<EmbeddedLeafFact> {
1301    brokk_bifrost_core::analyzer::structural::regex::facts_to_embedded(
1302        facts,
1303        &decoded.chars,
1304        decoded.source_range,
1305        source,
1306    )
1307}
1308
1309fn parse_pattern(decoded: &DecodedValue, flags: RegexFlags, api: Api) -> Vec<Fact> {
1310    RegexParser::new(
1311        &decoded.value,
1312        flags,
1313        api.scans_from_each_offset,
1314        &PythonRegexSyntax,
1315    )
1316    .parse()
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use super::*;
1322    use brokk_bifrost_test_macros::hardwon;
1323
1324    #[test]
1325    fn public_string_value_decoder_decodes_escaped_format_braces() {
1326        let source = r#"value = "\x7b0\x7d""#;
1327        let mut parser = tree_sitter::Parser::new();
1328        parser
1329            .set_language(&tree_sitter_python::LANGUAGE.into())
1330            .expect("Python grammar is valid");
1331        let tree = parser.parse(source, None).expect("parser returns a tree");
1332        let mut pending = vec![tree.root_node()];
1333        let mut string = None;
1334        while let Some(node) = pending.pop() {
1335            if node.kind() == "string" {
1336                string = Some(node);
1337                break;
1338            }
1339            let mut cursor = node.walk();
1340            pending.extend(node.named_children(&mut cursor));
1341        }
1342        let string = string.expect("fixture contains a string expression");
1343        let mut cursor = string.walk();
1344        let parts = string
1345            .named_children(&mut cursor)
1346            .map(|child| (child.kind().to_owned(), node_text(child, source).to_owned()))
1347            .collect::<Vec<_>>();
1348        assert_eq!(
1349            decode_python_string_value(string, source).expect("string literal decodes"),
1350            "{0}",
1351            "parts={parts:?}; string={}",
1352            string.to_sexp()
1353        );
1354    }
1355
1356    fn embedded_for_source(source: &str) -> Vec<EmbeddedLeafFact> {
1357        let mut parser = tree_sitter::Parser::new();
1358        parser
1359            .set_language(&tree_sitter_python::LANGUAGE.into())
1360            .expect("Python grammar is valid");
1361        let tree = parser.parse(source, None).expect("parser returns a tree");
1362        let root = tree.root_node();
1363        let mut pending = vec![root];
1364        let mut facts = module_assigned_facts(root, source);
1365        while let Some(node) = pending.pop() {
1366            if matches!(node.kind(), "call" | "string" | "concatenated_string") {
1367                facts.extend(embedded_facts(node, source));
1368            }
1369            let mut children = Vec::new();
1370            let mut cursor = node.walk();
1371            for child in node.named_children(&mut cursor) {
1372                children.push(child);
1373            }
1374            pending.extend(children.into_iter().rev());
1375        }
1376        facts
1377    }
1378
1379    #[hardwon(
1380        "#4874: bounded repetition cloned a fragment after concatenation linked its end outside"
1381    )]
1382    #[test]
1383    fn meshroom_assigned_regex_and_reduced_form_compile_without_incomplete_facts() {
1384        let patterns = [
1385            r"(?P<FILESTEM_PREFIX>.*?)(?P<FRAMEID_STR>[-._]\d+)?(?P<EXTENSION>\.\w{3,4})",
1386            r"(?P<run>a{3})",
1387        ];
1388
1389        for pattern in patterns {
1390            let source = format!("import re\npattern = r\"{pattern}\"\nre.compile(pattern)\n");
1391            let facts = embedded_for_source(&source);
1392            assert!(
1393                facts
1394                    .iter()
1395                    .any(|fact| fact.kind == NormalizedKind::RegexPattern),
1396                "pattern={pattern:?}; facts={facts:#?}"
1397            );
1398            assert!(
1399                facts
1400                    .iter()
1401                    .all(|fact| fact.kind != NormalizedKind::RegexIncomplete),
1402                "pattern={pattern:?}; facts={facts:#?}"
1403            );
1404        }
1405
1406        let unsupported = embedded_for_source(
1407            "import re\npattern = r\"(?P<anchored>^a)\"\nre.compile(pattern)\n",
1408        );
1409        assert!(
1410            unsupported.iter().any(|fact| {
1411                fact.kind == NormalizedKind::RegexIncomplete
1412                    && fact.construct.as_deref() == Some("bad-tag-nfa-unsupported")
1413            }),
1414            "unsupported NFA construct must be explicit; facts={unsupported:#?}"
1415        );
1416    }
1417
1418    #[hardwon(
1419        "#4650: Python regex facts missed byte patterns, nested assigned values, Unicode ranges, and ReDoS"
1420    )]
1421    #[test]
1422    fn codeql_regex_facts_cover_byte_values_assignments_and_safe_redos_controls() {
1423        let source = r####"import re
1424re.compile(br"[\b\t ]")
1425re.compile(b"[AA]")
1426re.compile(br"(P<name>[\w]+)")
1427re.compile(b" ^abc")
1428re.compile(b"(?s) ^abc")
1429re.compile(b"abc$ ")
1430re.compile(r"[A-z]")
1431re.compile(r"^[z-a]$")
1432re.compile(r"[0-93-5]")
1433re.compile(r"[0-9\d]")
1434re.compile('[\U0001D173-\U0001D17A\U000E0020-\U000E007F\U000e0001]')
1435re.compile(r"<script.*?>.*?</script>", re.I)
1436re.compile(r"<script.*?>.*?</script>", re.re.DOTALL)
1437re.compile(r"""(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|<!(--.*?--\s*)+>)""", re.I)
1438VERBOSE_REGEX = r"""
1439        \[
1440        (?P<header>[^]]+)                  # very permissive!
1441        \]
1442        """
1443re.compile(VERBOSE_REGEX, re.VERBOSE)
1444re.compile(r"(a|.)*")
1445re.compile(r"(a|aa?)*b")
1446re.compile(r"^([a-z]+)+$")
1447MY_REG = r'X(\u0061|a)*Y'
1448def matcher(name):
1449    re.match(MY_REG, name)
1450def shadowed(name, MY_REG):
1451    re.match(MY_REG, name)
1452re.compile(r"((.|[^a])*)\"")
1453re.compile(r"((a|[^a])*)\"")
1454re.compile(r"(a+)+aaaaa*a+")
1455"####;
1456        let facts = embedded_for_source(source);
1457        let has = |kind| facts.iter().any(|fact| fact.kind == kind);
1458        assert!(has(NormalizedKind::RegexRiskBackspaceEscape), "{facts:#?}");
1459        assert!(
1460            has(NormalizedKind::RegexRiskDuplicateClassCharacter),
1461            "{facts:#?}"
1462        );
1463        assert!(
1464            has(NormalizedKind::RegexRiskIncompleteSpecialGroup),
1465            "{facts:#?}"
1466        );
1467        assert!(has(NormalizedKind::RegexRiskUnmatchableCaret), "{facts:#?}");
1468        assert!(
1469            has(NormalizedKind::RegexRiskUnmatchableDollar),
1470            "{facts:#?}"
1471        );
1472        assert!(has(NormalizedKind::RegexRiskRange), "{facts:#?}");
1473        assert!(has(NormalizedKind::RegexRiskBadTag), "{facts:#?}");
1474        assert!(has(NormalizedKind::RegexRiskExponential), "{facts:#?}");
1475        let overlapping_range_start = source
1476            .find("[0-93-5]")
1477            .expect("overlapping range sample is present")
1478            + 1;
1479        assert!(
1480            facts.iter().any(|fact| {
1481                fact.kind == NormalizedKind::RegexRiskRange
1482                    && fact.range.start_byte == overlapping_range_start
1483                    && fact.range.end_byte == overlapping_range_start + 3
1484            }),
1485            "{facts:#?}"
1486        );
1487        let line_with = |needle: &str| {
1488            source
1489                .lines()
1490                .position(|line| line.contains(needle))
1491                .expect("regex fixture line is present")
1492                + 1
1493        };
1494        let risk_lines = facts
1495            .iter()
1496            .filter(|fact| fact.kind == NormalizedKind::RegexRiskExponential)
1497            .map(|fact| fact.range.start_line)
1498            .collect::<HashSet<_>>();
1499        assert!(risk_lines.contains(&line_with("^([a-z]+)+$")), "{facts:#?}");
1500        assert!(risk_lines.contains(&line_with("MY_REG =")), "{facts:#?}");
1501        assert!(risk_lines.contains(&line_with("((.|[^a])*)")), "{facts:#?}");
1502        assert!(
1503            !risk_lines.contains(&line_with("((a|[^a])*)")),
1504            "{facts:#?}"
1505        );
1506        assert!(!risk_lines.contains(&line_with("aaaaa*a+")), "{facts:#?}");
1507        let shadowed_line = source
1508            .lines()
1509            .enumerate()
1510            .filter_map(|(index, line)| {
1511                line.contains("re.match(MY_REG, name)").then_some(index + 1)
1512            })
1513            .last()
1514            .expect("shadowed pattern reference is present");
1515        assert!(
1516            facts.iter().any(|fact| {
1517                fact.kind == NormalizedKind::RegexIncomplete
1518                    && fact.range.start_line == shadowed_line
1519                    && fact
1520                        .construct
1521                        .as_deref()
1522                        .is_some_and(|reason| reason.starts_with("dynamic-pattern:"))
1523            }),
1524            "{facts:#?}"
1525        );
1526        assert!(
1527            facts.iter().any(|fact| {
1528                fact.kind == NormalizedKind::RegexRiskRange
1529                    && fact.range.start_line == line_with("U0001D173")
1530            }),
1531            "{facts:#?}"
1532        );
1533        assert!(
1534            !facts.iter().any(|fact| {
1535                fact.kind == NormalizedKind::RegexRiskDuplicateClassCharacter
1536                    && fact.range.start_line == line_with("VERBOSE_REGEX =")
1537            }),
1538            "{facts:#?}"
1539        );
1540        assert!(
1541            facts.iter().any(|fact| {
1542                fact.kind == NormalizedKind::RegexRiskBadTag
1543                    && fact.range.start_line == line_with("<[a-z")
1544            }),
1545            "{facts:#?}"
1546        );
1547        let nested_flag_line = source
1548            .lines()
1549            .position(|line| line.contains("re.re.DOTALL"))
1550            .expect("upstream nested DOTALL flag case is present")
1551            + 1;
1552        assert!(facts.iter().any(|fact| {
1553            fact.kind == NormalizedKind::RegexRiskBadTag
1554                && fact.range.start_line == nested_flag_line
1555        }));
1556        let safe_line = source
1557            .lines()
1558            .position(|line| line.contains("(a|.)*"))
1559            .expect("safe redos control is present")
1560            + 1;
1561        assert!(!facts.iter().any(|fact| {
1562            fact.kind == NormalizedKind::RegexRiskExponential && fact.range.start_line == safe_line
1563        }));
1564        assert!(facts.iter().any(|fact| {
1565            fact.kind == NormalizedKind::RegexPattern
1566                && fact
1567                    .construct
1568                    .as_deref()
1569                    .is_some_and(|pattern| pattern.contains("very permissive"))
1570        }));
1571    }
1572
1573    #[test]
1574    fn decodes_python_unknown_escapes_and_adjacent_literals_before_regex_parse() {
1575        let source = r#"import re
1576re.compile("\d" "\." r"\.")
1577"#;
1578        let facts = embedded_for_source(source);
1579        let pattern = facts
1580            .iter()
1581            .find(|fact| fact.kind == NormalizedKind::RegexPattern)
1582            .expect("selected compile call has a regex pattern fact");
1583        assert_eq!(pattern.construct.as_deref(), Some(r"\d\.\."));
1584        assert!(
1585            facts
1586                .iter()
1587                .any(|fact| fact.kind == NormalizedKind::RegexEscape)
1588        );
1589        assert!(
1590            !facts
1591                .iter()
1592                .any(|fact| fact.kind == NormalizedKind::RegexIncomplete)
1593        );
1594    }
1595
1596    #[test]
1597    fn selects_only_recognized_pattern_arguments_and_marks_dynamic_values_incomplete() {
1598        let source = r#"import re
1599re.compile("selected")
1600re.sub("replacement", "pattern", "subject")
1601other.compile("unrecognized")
1602re.compile(pattern)
1603"#;
1604        let facts = embedded_for_source(source);
1605        let patterns = facts
1606            .iter()
1607            .filter(|fact| fact.kind == NormalizedKind::RegexPattern)
1608            .map(|fact| fact.construct.as_deref().expect("pattern stores value"))
1609            .collect::<Vec<_>>();
1610        assert_eq!(patterns, ["selected", "replacement"]);
1611        assert!(facts.iter().any(|fact| {
1612            fact.kind == NormalizedKind::RegexIncomplete
1613                && fact
1614                    .construct
1615                    .as_deref()
1616                    .is_some_and(|construct| construct.starts_with("dynamic-pattern:"))
1617        }));
1618        assert!(!patterns.contains(&"pattern"));
1619        assert!(!patterns.contains(&"subject"));
1620        assert!(!patterns.contains(&"unrecognized"));
1621    }
1622
1623    #[test]
1624    fn supported_lookaround_groups_and_orphan_quantifiers_have_typed_facts() {
1625        let facts = embedded_for_source(
1626            "import re\nre.compile(r\"(?=lookahead)\")\nre.compile(r\"a$*\")\n",
1627        );
1628        assert!(facts.iter().any(|fact| {
1629            fact.kind == NormalizedKind::RegexGroup
1630                && fact.construct.as_deref() == Some("lookahead-positive")
1631        }));
1632        assert!(facts.iter().any(|fact| {
1633            fact.kind == NormalizedKind::RegexIncomplete
1634                && fact.construct.as_deref() == Some("quantifier-without-atom")
1635        }));
1636    }
1637
1638    #[hardwon(
1639        "#4454: zero-width regex facts escaped their embedded parent and aborted workspace indexing"
1640    )]
1641    #[test]
1642    fn zero_width_regex_facts_stay_within_their_embedded_parents() {
1643        for (source, malformed) in [
1644            ("import re\nre.compile(r'^(v)(.*)()$')\n", false),
1645            ("import re\nre.subn(\"()\"r' am I'\"??\")\n", false),
1646            (
1647                "import re\nre.compile(r\"invalid literal for int().*: '?'?\")\n",
1648                false,
1649            ),
1650            ("import re\nre.compile(\"ab()+cd\")\n", false),
1651            ("import re\nre.sub('()x', r'\\g<0>\\g<0>', 'xx')\n", false),
1652            ("import re\nre.search('(', 'test')\n", true),
1653        ] {
1654            let facts = embedded_for_source(source);
1655            assert!(
1656                facts
1657                    .iter()
1658                    .any(|fact| fact.kind == NormalizedKind::RegexPattern),
1659                "source={source:?}; facts={facts:#?}"
1660            );
1661            for (fact_id, fact) in facts.iter().enumerate() {
1662                if let Some(parent) = fact.parent {
1663                    assert!(parent < fact_id, "source={source:?}; facts={facts:#?}");
1664                    let parent_fact = &facts[parent];
1665                    assert!(
1666                        parent_fact.range.start_byte <= fact.range.start_byte
1667                            && fact.range.end_byte <= parent_fact.range.end_byte,
1668                        "source={source:?}; fact={fact:#?}; parent={parent_fact:#?}"
1669                    );
1670                }
1671            }
1672            if !malformed {
1673                assert!(
1674                    facts.iter().any(|fact| {
1675                        fact.kind == NormalizedKind::RegexSequence
1676                            && fact.range.start_byte == fact.range.end_byte
1677                    }),
1678                    "source={source:?} retains its zero-width empty-group sequence; facts={facts:#?}"
1679                );
1680            }
1681            if malformed {
1682                assert!(
1683                    facts
1684                        .iter()
1685                        .any(|fact| fact.kind == NormalizedKind::RegexIncomplete),
1686                    "malformed pattern remains structurally unavailable; facts={facts:#?}"
1687                );
1688            }
1689        }
1690    }
1691
1692    #[test]
1693    fn polynomial_redos_facts_cover_search_restarts_and_overlapping_repetitions() {
1694        for (source, expected) in [
1695            ("import re\nre.search(r'\\s+$', value)\n", true),
1696            ("import re\nre.search(r'^\\s+$', value)\n", false),
1697            ("import re\nre.search(r'^\\d+E?\\d+$', value)\n", true),
1698            ("import re\nre.search(r'^\\d+E\\d+$', value)\n", false),
1699            ("import re\nre.match(r'\\s+$', value)\n", false),
1700            ("import re\nre.compile(r'\\s+$')\n", false),
1701        ] {
1702            let facts = embedded_for_source(source);
1703            assert_eq!(
1704                facts
1705                    .iter()
1706                    .any(|fact| fact.kind == NormalizedKind::RegexRiskPolynomial),
1707                expected,
1708                "source={source:?}; facts={facts:#?}"
1709            );
1710        }
1711    }
1712
1713    #[test]
1714    #[hardwon("#4443: an empty embedded regex sequence escaped its parent source range")]
1715    fn unquantified_empty_group_keeps_embedded_regex_facts_inside_parents() {
1716        let source = "import re\nre.search(r'function (no)?watchplus()', webpage)\n";
1717        let facts = embedded_for_source(source);
1718        assert!(
1719            facts
1720                .iter()
1721                .any(|fact| fact.kind == NormalizedKind::RegexPattern)
1722        );
1723        for (index, fact) in facts.iter().enumerate() {
1724            if let Some(parent) = fact.parent {
1725                assert!(parent < index, "{fact:?}");
1726                let parent_fact = &facts[parent];
1727                assert!(
1728                    parent_fact.range.start_byte <= fact.range.start_byte
1729                        && fact.range.end_byte <= parent_fact.range.end_byte,
1730                    "child {fact:?} is outside parent {parent_fact:?}"
1731                );
1732            }
1733        }
1734        let closing_paren = source.find("()'").expect("trailing empty group") + 1;
1735        assert!(facts.iter().any(|fact| {
1736            fact.kind == NormalizedKind::RegexSequence
1737                && fact.range.start_byte == closing_paren
1738                && fact.range.end_byte == closing_paren
1739        }));
1740    }
1741    #[test]
1742    fn qs6_regex_decoder_and_fact_budgets_keep_ranges_and_parent_links() {
1743        for (pattern, reason, fact_count) in [
1744            (
1745                "f'{dynamic}'".to_owned(),
1746                "formatted-string-interpolation",
1747                1,
1748            ),
1749            (
1750                format!("r'{}'", "a".repeat(8193)),
1751                "pattern-character-budget",
1752                1,
1753            ),
1754            (
1755                format!("r'{}'", "a|".repeat(2200) + "a"),
1756                "regex-fact-budget",
1757                3,
1758            ),
1759        ] {
1760            let prefix = "# \u{e9}\nimport re\nre.compile(";
1761            let source = format!("{prefix}{pattern})\n");
1762            let facts = embedded_for_source(&source);
1763            assert_eq!(facts.len(), fact_count, "reason={reason}; facts={facts:?}");
1764            let incomplete = facts
1765                .iter()
1766                .find(|fact| fact.kind == NormalizedKind::RegexIncomplete)
1767                .expect("limit explicitly reported");
1768            assert_eq!(
1769                incomplete.construct.as_deref(),
1770                Some(reason),
1771                "{incomplete:?}"
1772            );
1773            let expected = if fact_count == 1 {
1774                prefix.len()..prefix.len() + pattern.len()
1775            } else {
1776                prefix.len() + 2..prefix.len() + pattern.len() - 1
1777            };
1778            assert_eq!(
1779                incomplete.range.start_byte..incomplete.range.end_byte,
1780                expected
1781            );
1782            for (index, fact) in facts.iter().enumerate() {
1783                assert!(source.is_char_boundary(fact.range.start_byte));
1784                assert!(source.is_char_boundary(fact.range.end_byte));
1785                if let Some(parent) = fact.parent {
1786                    assert!(parent < index, "{facts:?}");
1787                    assert!(
1788                        facts[parent].range.start_byte <= fact.range.start_byte
1789                            && fact.range.end_byte <= facts[parent].range.end_byte,
1790                        "{facts:?}"
1791                    );
1792                }
1793            }
1794        }
1795        assert!(
1796            embedded_for_source("import re\nre.compile(r'ab')\n")
1797                .iter()
1798                .any(|f| f.kind == NormalizedKind::RegexPattern)
1799        );
1800    }
1801
1802    #[test]
1803    fn qs6_integer_flags_have_radix_independent_semantics() {
1804        for (mask, complete) in [
1805            (0_u64, true),
1806            (2, true),
1807            (8, true),
1808            (16, true),
1809            (256, true),
1810            (282, true),
1811            (1, false),
1812            (64, false),
1813        ] {
1814            let mut reference = None;
1815            for digits in [
1816                mask.to_string(),
1817                format!("0x{mask:x}"),
1818                format!("0o{mask:o}"),
1819                format!("0b{mask:b}"),
1820            ] {
1821                // Python permits separators between digits in every radix.
1822                let (prefix, digits) = if digits.starts_with('0') && digits.len() > 1 {
1823                    digits.split_at(2)
1824                } else {
1825                    ("", digits.as_str())
1826                };
1827                let separated = digits
1828                    .chars()
1829                    .map(|ch| ch.to_string())
1830                    .collect::<Vec<_>>()
1831                    .join("_");
1832                let source = format!("import re\nre.compile(r'^a.\\w$', {prefix}{separated})\n");
1833                let tree = crate::declarations::parse_python_tree(&source).unwrap();
1834                let call = tree
1835                    .root_node()
1836                    .named_child(1)
1837                    .unwrap()
1838                    .named_child(0)
1839                    .unwrap();
1840                assert_eq!(call.kind(), "call");
1841                let integer = call
1842                    .child_by_field_name("arguments")
1843                    .unwrap()
1844                    .named_child(1)
1845                    .unwrap();
1846                assert_eq!(integer.kind(), "integer");
1847                assert_eq!(node_text(integer, &source), format!("{prefix}{separated}"));
1848                let api =
1849                    recognized_api(call.child_by_field_name("function").unwrap(), &source).unwrap();
1850                let flags = call_flags(call, api, &source);
1851                let expected = match mask {
1852                    2 => (true, false, false, false),
1853                    8 => (false, true, false, false),
1854                    16 => (false, false, true, false),
1855                    256 => (false, false, false, true),
1856                    282 => (true, true, true, true),
1857                    _ => (false, false, false, false),
1858                };
1859                assert_eq!(
1860                    (flags.ignorecase, flags.multiline, flags.dotall, flags.ascii),
1861                    expected
1862                );
1863                let facts = embedded_for_source(&source);
1864                assert!(
1865                    facts
1866                        .iter()
1867                        .any(|fact| fact.kind == NormalizedKind::RegexPattern)
1868                );
1869                assert_eq!(
1870                    !facts
1871                        .iter()
1872                        .any(|fact| fact.construct.as_deref() == Some("unsupported-regex-flags")),
1873                    complete,
1874                    "mask={mask}: {facts:?}"
1875                );
1876                let semantic = facts
1877                    .iter()
1878                    .map(|fact| (fact.kind, fact.parent, fact.construct.clone()))
1879                    .collect::<Vec<_>>();
1880                if let Some(reference) = &reference {
1881                    assert_eq!(&semantic, reference);
1882                } else {
1883                    reference = Some(semantic);
1884                }
1885            }
1886        }
1887        for overflow in [
1888            "18446744073709551616",
1889            "0x1_0000_0000_0000_0000",
1890            "0o2_0000_0000_0000_0000_0000_00",
1891            "0b1_0000000000000000000000000000000000000000000000000000000000000000",
1892        ] {
1893            let source = format!("import re\nre.compile('a', {overflow})\n");
1894            assert!(
1895                embedded_for_source(&source)
1896                    .iter()
1897                    .any(|fact| fact.construct.as_deref() == Some("unsupported-regex-flags")),
1898                "{overflow}"
1899            );
1900        }
1901    }
1902    #[test]
1903    #[hardwon(
1904        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: ignored comments produced duplicate-class warnings"
1905    )]
1906    fn qs6_verbose_comments_are_ignored() {
1907        let facts = embedded_for_source("import re\nre.compile('[a] # xx', re.X)\n");
1908        assert!(
1909            facts
1910                .iter()
1911                .any(|f| f.kind == NormalizedKind::RegexCharacterClass)
1912        );
1913        assert!(
1914            !facts
1915                .iter()
1916                .any(|f| f.kind == NormalizedKind::RegexRiskDuplicateClassCharacter),
1917            "{facts:?}"
1918        );
1919    }
1920
1921    #[test]
1922    #[hardwon(
1923        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: group widths lost quantifiers and comment emptiness"
1924    )]
1925    fn qs6_group_width_anchor_regression() {
1926        for (pattern, risk) in [
1927            ("(a)*^", false),
1928            ("(a)?^", false),
1929            ("(a){0,3}^", false),
1930            ("(a)+^", true),
1931            ("(?#ignored)a^", true),
1932        ] {
1933            let facts = embedded_for_source(&format!("import re\nre.compile(r'{pattern}')\n"));
1934            assert_eq!(
1935                facts
1936                    .iter()
1937                    .any(|f| f.kind == NormalizedKind::RegexRiskUnmatchableCaret),
1938                risk,
1939                "{pattern}: {facts:?}"
1940            );
1941        }
1942    }
1943
1944    #[test]
1945    #[hardwon(
1946        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: optional clones lost HTML capture evidence"
1947    )]
1948    fn qs6_optional_captures_retain_bad_tag_evidence() {
1949        let facts = embedded_for_source("import re\nre.compile(r'(?:(<!--.*?--)|(<foo))?>')\n");
1950        assert!(
1951            facts
1952                .iter()
1953                .any(|f| f.kind == NormalizedKind::RegexRiskBadTag),
1954            "{facts:?}"
1955        );
1956    }
1957
1958    #[test]
1959    #[hardwon(
1960        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: multiple repetition operators were accepted"
1961    )]
1962    fn qs6_multiple_repeat_is_incomplete() {
1963        for pattern in ["a**", "a*+*", "(ab){2}{3}"] {
1964            let facts = embedded_for_source(&format!("import re\nre.compile(r'{pattern}')\n"));
1965            assert!(
1966                facts
1967                    .iter()
1968                    .any(|f| f.construct.as_deref() == Some("nested-quantifier")),
1969                "{pattern}: {facts:?}"
1970            );
1971        }
1972    }
1973
1974    fn qs6_parse(pattern: &str) -> Vec<Fact> {
1975        let decoded = DecodedValue {
1976            value: pattern.to_owned(),
1977            chars: Vec::new(),
1978            source_range: Range {
1979                start_byte: 0,
1980                end_byte: pattern.len(),
1981                start_line: 1,
1982                end_line: 1,
1983            },
1984            is_bytes: false,
1985        };
1986        parse_pattern(
1987            &decoded,
1988            RegexFlags {
1989                dialect_flags: 0,
1990                dotall: false,
1991                multiline: false,
1992                ignorecase: false,
1993                ascii: false,
1994                verbose: false,
1995                complete: true,
1996            },
1997            Api {
1998                flags_position: 1,
1999                scans_from_each_offset: false,
2000            },
2001        )
2002    }
2003
2004    // CPython re.match oracle, generated over every word through length three
2005    // on {a, b, A, newline}, plus repeat-limit boundary witnesses.
2006    #[test]
2007    fn qs6_cpython_prefix_language_corpus() {
2008        let samples = [
2009            "",
2010            "a",
2011            "b",
2012            "A",
2013            "\n",
2014            "aa",
2015            "ab",
2016            "aA",
2017            "a\n",
2018            "ba",
2019            "bb",
2020            "bA",
2021            "b\n",
2022            "Aa",
2023            "Ab",
2024            "AA",
2025            "A\n",
2026            "\na",
2027            "\nb",
2028            "\nA",
2029            "\n\n",
2030            "aaa",
2031            "aab",
2032            "aaA",
2033            "aa\n",
2034            "aba",
2035            "abb",
2036            "abA",
2037            "ab\n",
2038            "aAa",
2039            "aAb",
2040            "aAA",
2041            "aA\n",
2042            "a\na",
2043            "a\nb",
2044            "a\nA",
2045            "a\n\n",
2046            "baa",
2047            "bab",
2048            "baA",
2049            "ba\n",
2050            "bba",
2051            "bbb",
2052            "bbA",
2053            "bb\n",
2054            "bAa",
2055            "bAb",
2056            "bAA",
2057            "bA\n",
2058            "b\na",
2059            "b\nb",
2060            "b\nA",
2061            "b\n\n",
2062            "Aaa",
2063            "Aab",
2064            "AaA",
2065            "Aa\n",
2066            "Aba",
2067            "Abb",
2068            "AbA",
2069            "Ab\n",
2070            "AAa",
2071            "AAb",
2072            "AAA",
2073            "AA\n",
2074            "A\na",
2075            "A\nb",
2076            "A\nA",
2077            "A\n\n",
2078            "\naa",
2079            "\nab",
2080            "\naA",
2081            "\na\n",
2082            "\nba",
2083            "\nbb",
2084            "\nbA",
2085            "\nb\n",
2086            "\nAa",
2087            "\nAb",
2088            "\nAA",
2089            "\nA\n",
2090            "\n\na",
2091            "\n\nb",
2092            "\n\nA",
2093            "\n\n\n",
2094            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2095            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2096            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2097        ];
2098        for (pattern, expected) in [
2099            (
2100                "a",
2101                "0100011110000000000001111111111111111000000000000000000000000000000000000000000000000111",
2102            ),
2103            (
2104                "b",
2105                "0010000001111000000000000000000000000111111111111111100000000000000000000000000000000000",
2106            ),
2107            (
2108                "[a]",
2109                "0100011110000000000001111111111111111000000000000000000000000000000000000000000000000111",
2110            ),
2111            (
2112                "[a-a]",
2113                "0100011110000000000001111111111111111000000000000000000000000000000000000000000000000111",
2114            ),
2115            (
2116                "[a-b]",
2117                "0110011111111000000001111111111111111111111111111111100000000000000000000000000000000111",
2118            ),
2119            (
2120                "[ab]",
2121                "0110011111111000000001111111111111111111111111111111100000000000000000000000000000000111",
2122            ),
2123            (
2124                "a?",
2125                "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
2126            ),
2127            (
2128                "ab?",
2129                "0100011110000000000001111111111111111000000000000000000000000000000000000000000000000111",
2130            ),
2131            (
2132                "a{0}",
2133                "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
2134            ),
2135            (
2136                "a{1}",
2137                "0100011110000000000001111111111111111000000000000000000000000000000000000000000000000111",
2138            ),
2139            (
2140                "a{3}",
2141                "0000000000000000000001000000000000000000000000000000000000000000000000000000000000000111",
2142            ),
2143            (
2144                "a{3,3}",
2145                "0000000000000000000001000000000000000000000000000000000000000000000000000000000000000111",
2146            ),
2147            (
2148                "a{2,3}",
2149                "0000010000000000000001111000000000000000000000000000000000000000000000000000000000000111",
2150            ),
2151            (
2152                "a{32}",
2153                "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000011",
2154            ),
2155            (
2156                ".",
2157                "0111011111111111100001111111111111111111111111111111111111111111111110000000000000000111",
2158            ),
2159            (
2160                "(?s:.)",
2161                "0111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
2162            ),
2163            (
2164                "(?ai:a)",
2165                "0101011110000111100001111111111111111000000000000000011111111111111110000000000000000111",
2166            ),
2167            (
2168                "(?ai:A)",
2169                "0101011110000111100001111111111111111000000000000000011111111111111110000000000000000111",
2170            ),
2171            (
2172                "(?ai:[a-z])",
2173                "0111011111111111100001111111111111111111111111111111111111111111111110000000000000000111",
2174            ),
2175            (
2176                "(?ai:[A-Z])",
2177                "0111011111111111100001111111111111111111111111111111111111111111111110000000000000000111",
2178            ),
2179            (
2180                "(a|aa)",
2181                "0100011110000000000001111111111111111000000000000000000000000000000000000000000000000111",
2182            ),
2183            (
2184                "(?:ab|b)*",
2185                "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
2186            ),
2187            (
2188                "(?:a|b)+",
2189                "0110011111111000000001111111111111111111111111111111100000000000000000000000000000000111",
2190            ),
2191            (
2192                "a{0,2}b",
2193                "0010001001111000000000100111100000000111111111111111100000000000000000000000000000000000",
2194            ),
2195        ] {
2196            let facts = qs6_parse(pattern);
2197            assert!(
2198                !facts
2199                    .iter()
2200                    .any(|f| f.kind == NormalizedKind::RegexIncomplete),
2201                "{pattern}: {facts:?}"
2202            );
2203            let nfa = RegexNfa::compile(&facts, 0, None).unwrap();
2204            for (sample, expected) in samples.iter().zip(expected.bytes()) {
2205                assert_eq!(
2206                    nfa.match_prefix_capture_groups(sample).0,
2207                    expected == b'1',
2208                    "{pattern:?} on {sample:?}"
2209                );
2210            }
2211        }
2212    }
2213
2214    #[test]
2215    fn qs6_literal_grammar_and_mapping_corpus() {
2216        // CPython ast.literal_eval values, preserving each grammar-selected
2217        // literal's source mapping rather than searching the source for syntax.
2218        for (literal, expected) in [
2219            (r"'\u0061'", "a"),
2220            (r"'\U00000061'", "a"),
2221            (r"b'\x61'", "a"),
2222            (r"b'\n'", "\n"),
2223            (r"r'\u0061'", r"\u0061"),
2224        ] {
2225            let tree = crate::declarations::parse_python_tree(literal).unwrap();
2226            let node = tree
2227                .root_node()
2228                .named_child(0)
2229                .unwrap()
2230                .named_child(0)
2231                .unwrap();
2232            assert_eq!(node.kind(), "string");
2233            let decoded = decode_string_literal(node, literal).unwrap();
2234            assert_eq!(decoded.value, expected, "{literal}");
2235        }
2236        for count in [8191, 8192, 8193] {
2237            let source = format!("rf'{}{{value}}'", "a".repeat(count));
2238            let tree = crate::declarations::parse_python_tree(&source).unwrap();
2239            let node = tree
2240                .root_node()
2241                .named_child(0)
2242                .unwrap()
2243                .named_child(0)
2244                .unwrap();
2245            let result = decode_python_fstring_literal_segments(node, &source);
2246            if count <= 8192 {
2247                assert_eq!(result.unwrap(), ["a".repeat(count), String::new()]);
2248            } else {
2249                assert_eq!(result, Err("pattern-character-budget"));
2250            }
2251        }
2252        for source in [
2253            "import re\nre.compile(r'''a\nb''')\n",
2254            "import re\nre.compile('a' '\\n' 'b')\n",
2255        ] {
2256            let facts = embedded_for_source(source);
2257            assert!(facts.iter().any(|f| f.kind == NormalizedKind::RegexLiteral));
2258            for fact in facts {
2259                let range = fact.range;
2260                assert_eq!(
2261                    range.start_line,
2262                    source[..range.start_byte]
2263                        .bytes()
2264                        .filter(|b| *b == b'\n')
2265                        .count()
2266                        + 1
2267                );
2268                assert_eq!(
2269                    range.end_line,
2270                    source[..range.end_byte.saturating_sub(1).max(range.start_byte)]
2271                        .bytes()
2272                        .filter(|b| *b == b'\n')
2273                        .count()
2274                        + 1
2275                );
2276            }
2277        }
2278    }
2279    #[test]
2280    fn qs6_anchor_and_redos_fact_corpus() {
2281        // Safe/unsafe controls follow zero-width assertion semantics and
2282        // whether a rejecting suffix forces enumeration of ambiguous partitions.
2283        for (pattern, flags, caret, dollar, exponential) in [
2284            ("^^a", "0", false, false, false),
2285            ("a$$", "0", false, false, false),
2286            ("(?=a)^a", "0", false, false, false),
2287            ("a^", "0", true, false, false),
2288            ("(|a)^", "0", false, false, false),
2289            ("(a|)^", "0", false, false, false),
2290            ("a$z", "0", false, true, false),
2291            (r"a\n^b", "re.M", false, false, false),
2292            ("([ab]+)+[a-b]+", "0", false, false, false),
2293            ("([a-b]+)+[a-b]+", "0", false, false, false),
2294            ("(a|aa)*(?=b)", "0", false, false, true),
2295            ("(a|aa)*(?=bb)", "0", false, false, true),
2296            ("(a|aa)*", "0", false, false, false),
2297        ] {
2298            let facts =
2299                embedded_for_source(&format!("import re\nre.compile(r'{pattern}', {flags})\n"));
2300            let has = |kind| facts.iter().any(|f| f.kind == kind);
2301            assert!(has(NormalizedKind::RegexPattern));
2302            assert!(
2303                !has(NormalizedKind::RegexIncomplete),
2304                "{pattern}: {facts:?}"
2305            );
2306            assert_eq!(
2307                (
2308                    has(NormalizedKind::RegexRiskUnmatchableCaret),
2309                    has(NormalizedKind::RegexRiskUnmatchableDollar),
2310                    has(NormalizedKind::RegexRiskExponential)
2311                ),
2312                (caret, dollar, exponential),
2313                "{pattern}: {facts:?}"
2314            );
2315        }
2316        for pattern in ["(ab)", "(^ab)", "(?:ab)"] {
2317            let facts = qs6_parse(pattern);
2318            let group = facts
2319                .iter()
2320                .find(|f| f.kind == NormalizedKind::RegexGroup)
2321                .unwrap();
2322            let first = group.first_set.as_ref().unwrap();
2323            assert!(first.contains('a'));
2324            assert!(!first.contains('b'), "{pattern}: {facts:?}");
2325        }
2326        for (pattern, broad) in [
2327            ("[:-B]", false),
2328            ("[:-C]", true),
2329            ("[:-D]", true),
2330            ("[A-Z]", false),
2331            ("[0-?]", false),
2332        ] {
2333            let facts = embedded_for_source(&format!("import re\nre.compile(r'{pattern}')\n"));
2334            assert_eq!(
2335                facts
2336                    .iter()
2337                    .any(|f| f.kind == NormalizedKind::RegexRiskRange),
2338                broad,
2339                "{pattern}: {facts:?}"
2340            );
2341        }
2342    }
2343
2344    #[test]
2345    fn qs6_selected_arguments_and_invalid_token_spans() {
2346        let source = "import re\nre.compile(pattern='a')\nre.search(string='subject', pattern='b')\nre.compile(other='unselected')\nre.sub('c', 'replacement', 'subject')\n";
2347        let facts = embedded_for_source(source);
2348        assert_eq!(
2349            facts
2350                .iter()
2351                .filter(|f| f.kind == NormalizedKind::RegexPattern)
2352                .map(|f| f.construct.as_deref().unwrap())
2353                .collect::<Vec<_>>(),
2354            ["a", "b", "c"]
2355        );
2356        for pattern in ["a|*", "a|+", "a|?", "(?P=name)", "a(?P=name)", "(abc"] {
2357            let prefix = "import re\nre.compile(r'";
2358            let source = format!("{prefix}{pattern}')\n");
2359            let facts = embedded_for_source(&source);
2360            let incomplete = facts
2361                .iter()
2362                .find(|f| f.kind == NormalizedKind::RegexIncomplete)
2363                .unwrap();
2364            let expected_start = if pattern.starts_with("a|") {
2365                2
2366            } else if pattern == "a(?P=name)" {
2367                1
2368            } else {
2369                0
2370            };
2371            let expected_end = if pattern.starts_with("a|") || pattern == "(?P=name)" {
2372                3
2373            } else if pattern == "a(?P=name)" {
2374                4
2375            } else {
2376                pattern.len()
2377            };
2378            assert_eq!(
2379                incomplete.range.start_byte..incomplete.range.end_byte,
2380                prefix.len() + expected_start..prefix.len() + expected_end,
2381                "{pattern}: {facts:?}"
2382            );
2383            // The public fact stream traverses siblings in lexical source order.
2384            for parent in 0..facts.len() {
2385                let children = facts
2386                    .iter()
2387                    .filter(|f| f.parent == Some(parent))
2388                    .map(|f| f.range.start_byte)
2389                    .collect::<Vec<_>>();
2390                assert!(
2391                    children.windows(2).all(|w| w[0] <= w[1]),
2392                    "{pattern}: {facts:?}"
2393                );
2394            }
2395        }
2396    }
2397
2398    #[test]
2399    #[hardwon(
2400        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: scoped flags could not start by disabling a flag"
2401    )]
2402    fn qs6_scoped_flags_can_disable_without_enabling() {
2403        for pattern in ["(?i:a)(?-i:b)", "(?-i:ab)"] {
2404            let facts = qs6_parse(pattern);
2405            assert!(
2406                !facts
2407                    .iter()
2408                    .any(|f| f.kind == NormalizedKind::RegexIncomplete),
2409                "{facts:?}"
2410            );
2411            let nfa = RegexNfa::compile(&facts, 0, None).unwrap();
2412            assert!(nfa.match_prefix_capture_groups("ab").0);
2413            assert!(!nfa.match_prefix_capture_groups("aB").0);
2414        }
2415        for pattern in ["(?-a:a)", "(?-u:a)", "(?-i)", "(?i-:a)"] {
2416            let facts = qs6_parse(pattern);
2417            assert!(
2418                facts
2419                    .iter()
2420                    .any(|f| f.construct.as_deref() == Some("invalid-inline-flags")),
2421                "{pattern}: {facts:?}"
2422            );
2423        }
2424    }
2425}