1use std::collections::{BTreeSet, HashMap};
2
3use streaming_iterator::StreamingIterator;
4use tree_sitter::{Node, Query, QueryCursor, Tree};
5
6use crate::rules::RuleId;
7use crate::suppress::parse_suppression;
8
9pub fn count_captures(query: &Query, capture_name: &str, node: Node, source: &[u8]) -> usize {
10 let idx = match query.capture_index_for_name(capture_name) {
11 Some(i) => i,
12 None => return 0,
13 };
14 let mut cursor = QueryCursor::new();
15 let mut matches = cursor.matches(query, node, source);
16 let mut count = 0;
17 while let Some(m) = matches.next() {
18 count += m.captures.iter().filter(|c| c.index == idx).count();
19 }
20 count
21}
22
23pub fn has_any_match(query: &Query, capture_name: &str, node: Node, source: &[u8]) -> bool {
24 let idx = match query.capture_index_for_name(capture_name) {
25 Some(i) => i,
26 None => return false,
27 };
28 let mut cursor = QueryCursor::new();
29 let mut matches = cursor.matches(query, node, source);
30 while let Some(m) = matches.next() {
31 if m.captures.iter().any(|c| c.index == idx) {
32 return true;
33 }
34 }
35 false
36}
37
38pub fn collect_mock_class_names<F>(
39 query: &Query,
40 node: Node,
41 source: &[u8],
42 extract_name: F,
43) -> Vec<String>
44where
45 F: Fn(&str) -> String,
46{
47 let var_idx = match query.capture_index_for_name("var_name") {
48 Some(i) => i,
49 None => return Vec::new(),
50 };
51 let mut cursor = QueryCursor::new();
52 let mut matches = cursor.matches(query, node, source);
53 let mut names = BTreeSet::new();
54 while let Some(m) = matches.next() {
55 for c in m.captures.iter().filter(|c| c.index == var_idx) {
56 if let Ok(var) = c.node.utf8_text(source) {
57 names.insert(extract_name(var));
58 }
59 }
60 }
61 names.into_iter().collect()
62}
63
64fn collect_capture_ranges(
66 query: &Query,
67 capture_name: &str,
68 node: Node,
69 source: &[u8],
70) -> Vec<(usize, usize)> {
71 let idx = match query.capture_index_for_name(capture_name) {
72 Some(i) => i,
73 None => return Vec::new(),
74 };
75 let mut ranges = Vec::new();
76 let mut cursor = QueryCursor::new();
77 let mut matches = cursor.matches(query, node, source);
78 while let Some(m) = matches.next() {
79 for c in m.captures.iter().filter(|c| c.index == idx) {
80 ranges.push((c.node.start_byte(), c.node.end_byte()));
81 }
82 }
83 ranges
84}
85
86pub fn count_captures_within_context(
89 outer_query: &Query,
90 outer_capture: &str,
91 inner_query: &Query,
92 inner_capture: &str,
93 node: Node,
94 source: &[u8],
95) -> usize {
96 let ranges = collect_capture_ranges(outer_query, outer_capture, node, source);
97 if ranges.is_empty() {
98 return 0;
99 }
100
101 let inner_idx = match inner_query.capture_index_for_name(inner_capture) {
102 Some(i) => i,
103 None => return 0,
104 };
105
106 let mut count = 0;
107 let mut cursor = QueryCursor::new();
108 let mut matches = cursor.matches(inner_query, node, source);
109 while let Some(m) = matches.next() {
110 for c in m.captures.iter().filter(|c| c.index == inner_idx) {
111 let start = c.node.start_byte();
112 let end = c.node.end_byte();
113 if ranges.iter().any(|(rs, re)| start >= *rs && end <= *re) {
114 count += 1;
115 }
116 }
117 }
118
119 count
120}
121
122const TRIVIAL_LITERALS: &[&str] = &[
125 "0",
126 "1",
127 "2",
128 "true",
129 "false",
130 "True",
131 "False",
132 "None",
133 "null",
134 "undefined",
135 "nil",
136 "\"\"",
137 "''",
138 "0.0",
139 "1.0",
140];
141
142pub fn count_duplicate_literals(
149 assertion_query: &Query,
150 node: Node,
151 source: &[u8],
152 literal_kinds: &[&str],
153) -> usize {
154 let ranges = collect_capture_ranges(assertion_query, "assertion", node, source);
155 if ranges.is_empty() {
156 return 0;
157 }
158
159 let mut counts: HashMap<String, usize> = HashMap::new();
161 let mut stack = vec![node];
162 while let Some(n) = stack.pop() {
163 let start = n.start_byte();
164 let end = n.end_byte();
165
166 let overlaps_any = ranges.iter().any(|(rs, re)| end > *rs && start < *re);
168 if !overlaps_any {
169 continue;
170 }
171
172 if literal_kinds.contains(&n.kind()) {
173 let in_assertion = ranges.iter().any(|(rs, re)| start >= *rs && end <= *re);
174 if in_assertion {
175 if let Ok(text) = n.utf8_text(source) {
176 if !TRIVIAL_LITERALS.contains(&text) {
177 *counts.entry(text.to_string()).or_insert(0) += 1;
178 }
179 }
180 }
181 }
182
183 for i in 0..n.child_count() {
184 if let Some(child) = n.child(i) {
185 stack.push(child);
186 }
187 }
188 }
189
190 counts.values().copied().max().unwrap_or(0)
191}
192
193pub fn count_custom_assertion_lines(source_lines: &[&str], patterns: &[String]) -> usize {
197 if patterns.is_empty() {
198 return 0;
199 }
200 source_lines
201 .iter()
202 .filter(|line| {
203 patterns
204 .iter()
205 .any(|p| !p.is_empty() && line.contains(p.as_str()))
206 })
207 .count()
208}
209
210pub fn apply_custom_assertion_fallback(
213 analysis: &mut crate::extractor::FileAnalysis,
214 source: &str,
215 patterns: &[String],
216) {
217 if patterns.is_empty() {
218 return;
219 }
220 let lines: Vec<&str> = source.lines().collect();
221 for func in &mut analysis.functions {
222 if func.analysis.assertion_count > 0 {
223 continue;
224 }
225 let start = func.line.saturating_sub(1);
227 let end = func.end_line.min(lines.len());
228 if start >= end {
229 continue;
230 }
231 let body_lines = &lines[start..end];
232 let count = count_custom_assertion_lines(body_lines, patterns);
233 func.analysis.assertion_count += count;
234 }
235}
236
237pub fn apply_same_file_helper_tracing(
251 analysis: &mut crate::extractor::FileAnalysis,
252 tree: &Tree,
253 source: &[u8],
254 call_query: &Query,
255 def_query: &Query,
256 assertion_query: &Query,
257) {
258 if !analysis
260 .functions
261 .iter()
262 .any(|f| f.analysis.assertion_count == 0)
263 {
264 return;
265 }
266
267 let root = tree.root_node();
268
269 let def_name_idx = match def_query.capture_index_for_name("def_name") {
271 Some(i) => i,
272 None => return,
273 };
274 let def_body_idx = match def_query.capture_index_for_name("def_body") {
275 Some(i) => i,
276 None => return,
277 };
278
279 let mut helper_bodies: HashMap<String, (usize, usize)> = HashMap::new();
280 {
281 let mut cursor = QueryCursor::new();
282 let mut matches = cursor.matches(def_query, root, source);
283 while let Some(m) = matches.next() {
284 let mut name: Option<String> = None;
285 let mut body_range: Option<(usize, usize)> = None;
286 for cap in m.captures {
287 if cap.index == def_name_idx {
288 name = cap.node.utf8_text(source).ok().map(|s| s.to_string());
289 } else if cap.index == def_body_idx {
290 body_range = Some((cap.node.start_byte(), cap.node.end_byte()));
291 }
292 }
293 if let (Some(n), Some(r)) = (name, body_range) {
294 helper_bodies.insert(n, r);
295 }
296 }
297 }
298
299 if helper_bodies.is_empty() {
300 return;
301 }
302
303 let line_starts: Vec<usize> =
305 std::iter::once(0)
306 .chain(source.iter().enumerate().filter_map(|(i, &b)| {
307 if b == b'\n' {
308 Some(i + 1)
309 } else {
310 None
311 }
312 }))
313 .collect();
314
315 let call_name_idx = match call_query.capture_index_for_name("call_name") {
317 Some(i) => i,
318 None => return,
319 };
320
321 for func in &mut analysis.functions {
322 if func.analysis.assertion_count > 0 {
323 continue;
324 }
325
326 let start_byte = line_starts
328 .get(func.line.saturating_sub(1))
329 .copied()
330 .unwrap_or(0);
331 let end_byte = line_starts
332 .get(func.end_line.min(line_starts.len()))
333 .copied()
334 .unwrap_or(source.len());
335
336 let mut called_names: BTreeSet<String> = BTreeSet::new();
338 {
339 let mut call_cursor = QueryCursor::new();
340 call_cursor.set_byte_range(start_byte..end_byte);
341 let mut call_matches = call_cursor.matches(call_query, root, source);
342 while let Some(m) = call_matches.next() {
343 for cap in m.captures {
344 if cap.index == call_name_idx {
345 if let Ok(name) = cap.node.utf8_text(source) {
346 called_names.insert(name.to_string());
347 }
348 }
349 }
350 }
351 }
352
353 let mut traced_count = 0usize;
355 for name in &called_names {
356 if let Some(&(body_start, body_end)) = helper_bodies.get(name.as_str()) {
357 if let Some(body_node) = root.descendant_for_byte_range(body_start, body_end) {
359 traced_count += count_captures(assertion_query, "assertion", body_node, source);
360 }
361 }
362 }
363
364 func.analysis.assertion_count += traced_count;
365 }
366}
367
368pub fn extract_suppression_from_previous_line(source: &str, start_row: usize) -> Vec<RuleId> {
369 if start_row == 0 {
370 return Vec::new();
371 }
372 let lines: Vec<&str> = source.lines().collect();
373 let prev_line = lines.get(start_row - 1).unwrap_or(&"");
374 parse_suppression(prev_line)
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn suppression_from_first_line_returns_empty() {
383 assert!(extract_suppression_from_previous_line("any source", 0).is_empty());
384 }
385
386 #[test]
387 fn suppression_from_previous_line_parses_comment() {
388 let source = "// exspec-ignore: T001\nfn test_foo() {}";
389 let result = extract_suppression_from_previous_line(source, 1);
390 assert_eq!(result.len(), 1);
391 assert_eq!(result[0].0, "T001");
392 }
393
394 #[test]
395 fn suppression_from_previous_line_no_comment() {
396 let source = "// normal comment\nfn test_foo() {}";
397 let result = extract_suppression_from_previous_line(source, 1);
398 assert!(result.is_empty());
399 }
400
401 #[test]
402 fn suppression_out_of_bounds_returns_empty() {
403 let source = "single line";
404 let result = extract_suppression_from_previous_line(source, 5);
405 assert!(result.is_empty());
406 }
407
408 fn python_language() -> tree_sitter::Language {
411 tree_sitter_python::LANGUAGE.into()
412 }
413
414 #[test]
415 fn count_captures_within_context_basic() {
416 let source = "def test_foo():\n assert obj._count == 1\n";
418 let mut parser = tree_sitter::Parser::new();
419 parser.set_language(&python_language()).unwrap();
420 let tree = parser.parse(source, None).unwrap();
421 let root = tree.root_node();
422
423 let assertion_query =
424 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
425 let private_query = Query::new(
426 &python_language(),
427 "(attribute attribute: (identifier) @private_access (#match? @private_access \"^_[^_]\"))",
428 )
429 .unwrap();
430
431 let count = count_captures_within_context(
432 &assertion_query,
433 "assertion",
434 &private_query,
435 "private_access",
436 root,
437 source.as_bytes(),
438 );
439 assert_eq!(count, 1, "should detect _count inside assert statement");
440 }
441
442 #[test]
443 fn count_captures_within_context_outside() {
444 let source = "def test_foo():\n x = obj._count\n assert x == 1\n";
446 let mut parser = tree_sitter::Parser::new();
447 parser.set_language(&python_language()).unwrap();
448 let tree = parser.parse(source, None).unwrap();
449 let root = tree.root_node();
450
451 let assertion_query =
452 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
453 let private_query = Query::new(
454 &python_language(),
455 "(attribute attribute: (identifier) @private_access (#match? @private_access \"^_[^_]\"))",
456 )
457 .unwrap();
458
459 let count = count_captures_within_context(
460 &assertion_query,
461 "assertion",
462 &private_query,
463 "private_access",
464 root,
465 source.as_bytes(),
466 );
467 assert_eq!(count, 0, "_count is outside assert, should not count");
468 }
469
470 #[test]
471 fn count_captures_within_context_no_outer() {
472 let source = "def test_foo():\n x = obj._count\n";
474 let mut parser = tree_sitter::Parser::new();
475 parser.set_language(&python_language()).unwrap();
476 let tree = parser.parse(source, None).unwrap();
477 let root = tree.root_node();
478
479 let assertion_query =
480 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
481 let private_query = Query::new(
482 &python_language(),
483 "(attribute attribute: (identifier) @private_access (#match? @private_access \"^_[^_]\"))",
484 )
485 .unwrap();
486
487 let count = count_captures_within_context(
488 &assertion_query,
489 "assertion",
490 &private_query,
491 "private_access",
492 root,
493 source.as_bytes(),
494 );
495 assert_eq!(count, 0, "no assertions, should return 0");
496 }
497
498 #[test]
499 fn count_captures_missing_capture_returns_zero() {
500 let lang = python_language();
501 let query = Query::new(&lang, "(assert_statement) @assertion").unwrap();
503 let source = "def test_foo():\n assert True\n";
504 let mut parser = tree_sitter::Parser::new();
505 parser.set_language(&lang).unwrap();
506 let tree = parser.parse(source, None).unwrap();
507 let root = tree.root_node();
508
509 let count = count_captures(&query, "nonexistent", root, source.as_bytes());
510 assert_eq!(count, 0, "missing capture name should return 0, not panic");
511 }
512
513 #[test]
514 fn collect_mock_class_names_missing_capture_returns_empty() {
515 let lang = python_language();
516 let query = Query::new(&lang, "(assert_statement) @assertion").unwrap();
518 let source = "def test_foo():\n assert True\n";
519 let mut parser = tree_sitter::Parser::new();
520 parser.set_language(&lang).unwrap();
521 let tree = parser.parse(source, None).unwrap();
522 let root = tree.root_node();
523
524 let names = collect_mock_class_names(&query, root, source.as_bytes(), |s| s.to_string());
525 assert!(
526 names.is_empty(),
527 "missing @var_name capture should return empty vec, not panic"
528 );
529 }
530
531 #[test]
532 fn count_captures_within_context_missing_capture() {
533 let source = "def test_foo():\n assert obj._count == 1\n";
535 let mut parser = tree_sitter::Parser::new();
536 parser.set_language(&python_language()).unwrap();
537 let tree = parser.parse(source, None).unwrap();
538 let root = tree.root_node();
539
540 let assertion_query =
541 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
542 let private_query = Query::new(
543 &python_language(),
544 "(attribute attribute: (identifier) @private_access (#match? @private_access \"^_[^_]\"))",
545 )
546 .unwrap();
547
548 let count = count_captures_within_context(
550 &assertion_query,
551 "nonexistent",
552 &private_query,
553 "private_access",
554 root,
555 source.as_bytes(),
556 );
557 assert_eq!(count, 0, "missing outer capture should return 0");
558
559 let count = count_captures_within_context(
561 &assertion_query,
562 "assertion",
563 &private_query,
564 "nonexistent",
565 root,
566 source.as_bytes(),
567 );
568 assert_eq!(count, 0, "missing inner capture should return 0");
569 }
570
571 #[test]
574 fn count_duplicate_literals_detects_repeated_value() {
575 let source = "def test_foo():\n assert calc(1) == 42\n assert calc(2) == 42\n assert calc(3) == 42\n";
576 let mut parser = tree_sitter::Parser::new();
577 parser.set_language(&python_language()).unwrap();
578 let tree = parser.parse(source, None).unwrap();
579 let root = tree.root_node();
580
581 let assertion_query =
582 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
583 let count = count_duplicate_literals(
584 &assertion_query,
585 root,
586 source.as_bytes(),
587 &["integer", "float", "string"],
588 );
589 assert_eq!(count, 3, "42 appears 3 times in assertions");
590 }
591
592 #[test]
593 fn count_duplicate_literals_trivial_excluded() {
594 let source =
596 "def test_foo():\n assert calc(1) == 0\n assert calc(2) == 0\n assert calc(1) == 0\n";
597 let mut parser = tree_sitter::Parser::new();
598 parser.set_language(&python_language()).unwrap();
599 let tree = parser.parse(source, None).unwrap();
600 let root = tree.root_node();
601
602 let assertion_query =
603 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
604 let count = count_duplicate_literals(
605 &assertion_query,
606 root,
607 source.as_bytes(),
608 &["integer", "float", "string"],
609 );
610 assert_eq!(count, 0, "0, 1, 2 are all trivial and should be excluded");
611 }
612
613 #[test]
614 fn count_duplicate_literals_no_assertions() {
615 let source = "def test_foo():\n x = 42\n y = 42\n z = 42\n";
616 let mut parser = tree_sitter::Parser::new();
617 parser.set_language(&python_language()).unwrap();
618 let tree = parser.parse(source, None).unwrap();
619 let root = tree.root_node();
620
621 let assertion_query =
622 Query::new(&python_language(), "(assert_statement) @assertion").unwrap();
623 let count = count_duplicate_literals(
624 &assertion_query,
625 root,
626 source.as_bytes(),
627 &["integer", "float", "string"],
628 );
629 assert_eq!(count, 0, "no assertions, should return 0");
630 }
631
632 #[test]
636 fn count_custom_assertion_lines_empty_patterns() {
637 let lines = vec!["util.assertEqual(x, 1)", "assert True"];
638 assert_eq!(count_custom_assertion_lines(&lines, &[]), 0);
639 }
640
641 #[test]
643 fn count_custom_assertion_lines_matching() {
644 let lines = vec![
645 " util.assertEqual(x, 1)",
646 " util.assertEqual(y, 2)",
647 " print(result)",
648 ];
649 let patterns = vec!["util.assertEqual(".to_string()];
650 assert_eq!(count_custom_assertion_lines(&lines, &patterns), 2);
651 }
652
653 #[test]
655 fn count_custom_assertion_lines_in_comment() {
656 let lines = vec![" # util.assertEqual(x, 1)", " pass"];
657 let patterns = vec!["util.assertEqual(".to_string()];
658 assert_eq!(count_custom_assertion_lines(&lines, &patterns), 1);
659 }
660
661 #[test]
663 fn count_custom_assertion_lines_no_match() {
664 let lines = vec![" result = compute(42)", " print(result)"];
665 let patterns = vec!["util.assertEqual(".to_string()];
666 assert_eq!(count_custom_assertion_lines(&lines, &patterns), 0);
667 }
668
669 #[test]
671 fn count_custom_assertion_lines_multiple_occurrences() {
672 let lines = vec![" myAssert(a) and myAssert(b)", " myAssert(c)"];
673 let patterns = vec!["myAssert(".to_string()];
674 assert_eq!(count_custom_assertion_lines(&lines, &patterns), 2);
676 }
677
678 #[test]
680 fn count_custom_assertion_lines_multiple_patterns() {
681 let lines = vec![" customCheck(x)"];
682 let patterns = vec!["util.assertEqual(".to_string(), "customCheck(".to_string()];
683 assert_eq!(count_custom_assertion_lines(&lines, &patterns), 1);
684 }
685
686 #[test]
690 fn apply_fallback_skips_functions_with_assertions() {
691 use crate::extractor::{FileAnalysis, TestAnalysis, TestFunction};
692
693 let source = "def test_foo():\n util.assertEqual(x, 1)\n assert True\n";
694 let mut analysis = FileAnalysis {
695 file: "test.py".to_string(),
696 functions: vec![TestFunction {
697 name: "test_foo".to_string(),
698 file: "test.py".to_string(),
699 line: 1,
700 end_line: 3,
701 analysis: TestAnalysis {
702 assertion_count: 1,
703 ..Default::default()
704 },
705 }],
706 has_pbt_import: false,
707 has_contract_import: false,
708 has_error_test: false,
709 has_relational_assertion: false,
710 parameterized_count: 0,
711 };
712 let patterns = vec!["util.assertEqual(".to_string()];
713 apply_custom_assertion_fallback(&mut analysis, source, &patterns);
714 assert_eq!(analysis.functions[0].analysis.assertion_count, 1);
715 }
716
717 #[test]
719 fn apply_fallback_increments_assertion_count() {
720 use crate::extractor::{FileAnalysis, TestAnalysis, TestFunction};
721
722 let source = "def test_foo():\n util.assertEqual(x, 1)\n util.assertEqual(y, 2)\n";
723 let mut analysis = FileAnalysis {
724 file: "test.py".to_string(),
725 functions: vec![TestFunction {
726 name: "test_foo".to_string(),
727 file: "test.py".to_string(),
728 line: 1,
729 end_line: 3,
730 analysis: TestAnalysis {
731 assertion_count: 0,
732 ..Default::default()
733 },
734 }],
735 has_pbt_import: false,
736 has_contract_import: false,
737 has_error_test: false,
738 has_relational_assertion: false,
739 parameterized_count: 0,
740 };
741 let patterns = vec!["util.assertEqual(".to_string()];
742 apply_custom_assertion_fallback(&mut analysis, source, &patterns);
743 assert_eq!(analysis.functions[0].analysis.assertion_count, 2);
744 }
745
746 #[test]
748 fn apply_fallback_empty_patterns_noop() {
749 use crate::extractor::{FileAnalysis, TestAnalysis, TestFunction};
750
751 let source = "def test_foo():\n util.assertEqual(x, 1)\n";
752 let mut analysis = FileAnalysis {
753 file: "test.py".to_string(),
754 functions: vec![TestFunction {
755 name: "test_foo".to_string(),
756 file: "test.py".to_string(),
757 line: 1,
758 end_line: 2,
759 analysis: TestAnalysis {
760 assertion_count: 0,
761 ..Default::default()
762 },
763 }],
764 has_pbt_import: false,
765 has_contract_import: false,
766 has_error_test: false,
767 has_relational_assertion: false,
768 parameterized_count: 0,
769 };
770 apply_custom_assertion_fallback(&mut analysis, source, &[]);
771 assert_eq!(analysis.functions[0].analysis.assertion_count, 0);
772 }
773
774 #[test]
777 fn empty_string_pattern_ignored() {
778 let lines = vec!["assert True", "x = 1", "print(result)"];
779 let patterns = vec!["".to_string()];
780 assert_eq!(
781 count_custom_assertion_lines(&lines, &patterns),
782 0,
783 "empty string pattern should not match any line"
784 );
785 }
786
787 #[test]
788 fn mixed_empty_and_valid_patterns() {
789 let lines = vec![" assert_custom(x)", " print(result)"];
790 let patterns = vec!["".to_string(), "assert_custom".to_string()];
791 assert_eq!(
792 count_custom_assertion_lines(&lines, &patterns),
793 1,
794 "only valid patterns should match"
795 );
796 }
797
798 #[test]
799 fn whitespace_only_pattern_matches() {
800 let lines = vec!["assert_true", "no_space_here"];
802 let patterns = vec![" ".to_string()];
803 assert_eq!(
804 count_custom_assertion_lines(&lines, &patterns),
805 0,
806 "whitespace pattern should not match lines without spaces"
807 );
808 let lines_with_space = vec!["assert true", "nospace"];
809 assert_eq!(
810 count_custom_assertion_lines(&lines_with_space, &patterns),
811 1,
812 "whitespace pattern should match lines containing spaces"
813 );
814 }
815
816 #[test]
819 fn apply_fallback_end_line_exceeds_source() {
820 use crate::extractor::{FileAnalysis, TestAnalysis, TestFunction};
821
822 let source = "def test_foo():\n custom_assert(x)\n";
823 let mut analysis = FileAnalysis {
824 file: "test.py".to_string(),
825 functions: vec![TestFunction {
826 name: "test_foo".to_string(),
827 file: "test.py".to_string(),
828 line: 1,
829 end_line: 12, analysis: TestAnalysis {
831 assertion_count: 0,
832 ..Default::default()
833 },
834 }],
835 has_pbt_import: false,
836 has_contract_import: false,
837 has_error_test: false,
838 has_relational_assertion: false,
839 parameterized_count: 0,
840 };
841 let patterns = vec!["custom_assert".to_string()];
842 apply_custom_assertion_fallback(&mut analysis, source, &patterns);
843 assert_eq!(
844 analysis.functions[0].analysis.assertion_count, 1,
845 "should handle end_line > source length without panic"
846 );
847 }
848
849 #[test]
850 fn apply_fallback_empty_string_pattern_noop() {
851 use crate::extractor::{FileAnalysis, TestAnalysis, TestFunction};
852
853 let source = "def test_foo():\n some_call(x)\n another_call(y)\n";
854 let mut analysis = FileAnalysis {
855 file: "test.py".to_string(),
856 functions: vec![TestFunction {
857 name: "test_foo".to_string(),
858 file: "test.py".to_string(),
859 line: 1,
860 end_line: 3,
861 analysis: TestAnalysis {
862 assertion_count: 0,
863 ..Default::default()
864 },
865 }],
866 has_pbt_import: false,
867 has_contract_import: false,
868 has_error_test: false,
869 has_relational_assertion: false,
870 parameterized_count: 0,
871 };
872 let patterns = vec!["".to_string()];
873 apply_custom_assertion_fallback(&mut analysis, source, &patterns);
874 assert_eq!(
875 analysis.functions[0].analysis.assertion_count, 0,
876 "empty-string-only patterns should not increment assertion_count"
877 );
878 }
879
880 #[test]
881 fn count_duplicate_literals_missing_capture() {
882 let source = "def test_foo():\n assert 42 == 42\n";
883 let mut parser = tree_sitter::Parser::new();
884 parser.set_language(&python_language()).unwrap();
885 let tree = parser.parse(source, None).unwrap();
886 let root = tree.root_node();
887
888 let query = Query::new(&python_language(), "(assert_statement) @something_else").unwrap();
890 let count = count_duplicate_literals(&query, root, source.as_bytes(), &["integer"]);
891 assert_eq!(count, 0, "missing @assertion capture should return 0");
892 }
893}