Skip to main content

exspec_core/
query_utils.rs

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
64/// Collect byte ranges of all captures matching `capture_name` in `query`.
65fn 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
86/// Count captures of `inner_capture` from `inner_query` that fall within
87/// byte ranges of `outer_capture` from `outer_query`.
88pub 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
122// Literals considered too common to flag as duplicates.
123// Cross-language superset: Python (True/False/None), JS (null/undefined), PHP/Ruby (nil).
124const 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
142/// Count the maximum number of times any non-trivial literal appears
143/// within assertion nodes of the given function node.
144///
145/// `assertion_query` must have an `@assertion` capture.
146/// `literal_kinds` lists the tree-sitter node kind names that represent literals
147/// for the target language (e.g., `["integer", "float", "string"]` for Python).
148pub 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    // Walk tree, collect literals within assertion ranges
160    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        // Prune subtrees that don't overlap with any assertion range
167        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
193/// Text-based fallback for T001 escape hatch. Patterns are literal substrings, not regex.
194/// Matches in comments, strings, and imports are included by design.
195/// Returns the number of source lines that contain any pattern as a substring.
196pub 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
210/// Apply custom assertion pattern fallback to functions with assertion_count == 0.
211/// Only functions with no detected assertions are augmented; others are untouched.
212pub 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        // line/end_line are 1-based
226        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
237/// Apply same-file helper tracing to augment assertion_count for functions with 0 assertions.
238///
239/// For each test function with assertion_count == 0, traces 1-hop function calls within the
240/// same file. If a called function's body contains assertions, those assertions are counted
241/// and added to the test function's assertion_count.
242///
243/// - Only 1-hop: calls from test → helper (not helper → helper)
244/// - Only functions with assertion_count == 0 are processed (early return for performance)
245/// - Missing/undefined called functions are silently ignored (no crash)
246///
247/// `call_query`: tree-sitter query with @call_name capture
248/// `def_query`: tree-sitter query with @def_name and @def_body captures
249/// `assertion_query`: language assertion query with @assertion capture
250pub 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    // Early return: no assertion-free functions → nothing to trace
259    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    // Step 1: Build helper definition map: name → body byte range
270    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    // Step 2: Build line-to-byte-offset map
304    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    // Step 3: For each assertion-free test function, trace helper calls
316    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        // Calculate byte range from 1-based line numbers
327        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        // Collect called function names within this test function's byte range
337        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        // For each unique called name, look up its body and count assertions
354        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                // Find the body node from the tree by byte range
358                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    // --- count_captures_within_context ---
409
410    fn python_language() -> tree_sitter::Language {
411        tree_sitter_python::LANGUAGE.into()
412    }
413
414    #[test]
415    fn count_captures_within_context_basic() {
416        // assert obj._count == 1 -> _count is inside assert_statement (@assertion)
417        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        // _count is outside assert -> should not count
445        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        // No assert statement at all
473        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        // Query with capture @assertion, but we ask for nonexistent name
502        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        // Query without @var_name capture
517        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        // Capture name doesn't exist in query -> defensive 0
534        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        // Wrong capture name for outer
549        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        // Wrong capture name for inner
560        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    // --- count_duplicate_literals ---
572
573    #[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        // All literals are trivial (0, 1, 2) - should return 0
595        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    // --- count_custom_assertion_lines ---
633
634    // TC-04: empty patterns -> 0
635    #[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    // TC-05: matching pattern returns correct count
642    #[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    // TC-06: pattern in comment still counts (by design)
654    #[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    // TC-07: no matches -> 0
662    #[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    // TC-08: same pattern on multiple lines returns line count
670    #[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        // Line count, not occurrence count: line 1 has 2 but counts as 1
675        assert_eq!(count_custom_assertion_lines(&lines, &patterns), 2);
676    }
677
678    // TC-16: multiple patterns, one matches
679    #[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    // --- apply_custom_assertion_fallback ---
687
688    // TC-09: assertion_count > 0 -> unchanged
689    #[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    // TC-10: assertion_count == 0 + custom match -> incremented
718    #[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    // Empty patterns -> no-op
747    #[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    // --- empty string pattern filter ---
775
776    #[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        // Whitespace-only patterns are NOT filtered (only empty string is)
801        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    // --- apply_custom_assertion_fallback edge cases ---
817
818    #[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, // well beyond source length (2 lines)
830                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        // Query without @assertion capture
889        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}