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