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