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 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
40pub(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
533pub 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
541pub 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 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
839struct 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 }
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 '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 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 #[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 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 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 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}