Skip to main content

brokk_bifrost_cpp/
test_detection.rs

1//! C++ test-file recognition and test-assertion smell detection.
2//!
3//! `analyzer/cpp/tests.rs` in `brokk-bifrost-analysis` keeps the empty
4//! `impl TestDetectionProvider for CppAnalyzer` and the analyzer-bound fixture
5//! suites; everything here is a pure function of the file's source text.
6//!
7//! C++ recognizes the four framework macro families lexically rather than from
8//! the parse tree: `TEST`/`TEST_F`/`TEST_P`/`TYPED_TEST` (gtest),
9//! `TEST_CASE`/`SCENARIO` (Catch2), `BOOST_AUTO_TEST_CASE` and `TEST_METHOD`
10//! (MSTest) all expand from macros the grammar cannot see through.
11
12use brokk_bifrost_core::analyzer::ProjectFile;
13use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
14use regex::Regex;
15use std::sync::LazyLock;
16
17static CPP_GTEST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
18    Regex::new(r#"(?s)\b(?:TEST|TEST_F|TEST_P|TYPED_TEST)\b\s*(?:/\*.*?\*/\s*)?\([^)]*\)\s*\{"#)
19        .expect("valid regex")
20});
21static CPP_CATCH2_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r#"(?s)\b(?:TEST_CASE|SCENARIO)\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
23});
24static CPP_BOOST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
25    Regex::new(r#"(?s)\bBOOST_AUTO_TEST_CASE\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
26});
27static CPP_MSTEST_METHOD_START_RE: LazyLock<Regex> =
28    LazyLock::new(|| Regex::new(r#"(?s)\bTEST_METHOD\b\s*\([^)]*\)\s*\{"#).expect("valid regex"));
29static CPP_ASSERT_TRUTH_RE: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r#"(?:EXPECT|ASSERT)_(?P<matcher>TRUE|FALSE)\s*\((?P<arg>[^)\n]+)\)"#)
31        .expect("valid regex")
32});
33static CPP_ASSERT_EQUALITY_RE: LazyLock<Regex> = LazyLock::new(|| {
34    Regex::new(
35        r#"(?:EXPECT|ASSERT)_(?P<matcher>EQ|NE)\s*\((?P<left>[^,\n]+?)\s*,\s*(?P<right>[^)\n]+)\)"#,
36    )
37    .expect("valid regex")
38});
39
40#[derive(Clone)]
41struct CppAssertionSignal {
42    kind: String,
43    score: i32,
44    shallow: bool,
45    reason: String,
46    excerpt: String,
47    start_byte: usize,
48}
49
50pub fn detect_cpp_test_assertion_smells(
51    file: &ProjectFile,
52    source: &str,
53    weights: &TestAssertionWeights,
54) -> Vec<TestAssertionSmell> {
55    let mut findings = Vec::new();
56    for regex in [
57        &*CPP_GTEST_TEST_START_RE,
58        &*CPP_CATCH2_TEST_START_RE,
59        &*CPP_BOOST_TEST_START_RE,
60        &*CPP_MSTEST_METHOD_START_RE,
61    ] {
62        for whole in regex.find_iter(source) {
63            let Some((body, body_start)) = extract_braced_body(source, whole.end() - 1) else {
64                continue;
65            };
66            analyze_cpp_test_case(file, body, body_start, weights, &mut findings);
67        }
68    }
69    findings
70}
71
72fn analyze_cpp_test_case(
73    file: &ProjectFile,
74    body: &str,
75    start_byte: usize,
76    weights: &TestAssertionWeights,
77    out: &mut Vec<TestAssertionSmell>,
78) {
79    let assertions = collect_cpp_assertions(body, weights);
80    let assertion_count = assertions.len() as i32;
81    let symbol = file.to_string();
82
83    if assertion_count == 0 {
84        out.push(TestAssertionSmell {
85            file: file.clone(),
86            enclosing_fq_name: symbol,
87            assertion_kind: "no-assertions".to_string(),
88            score: weights.no_assertion_weight,
89            assertion_count: 0,
90            reasons: vec!["no-assertions".to_string()],
91            excerpt: compact_cpp_excerpt(body),
92            start_byte,
93        });
94        return;
95    }
96
97    for assertion in &assertions {
98        if assertion.score <= 0 {
99            continue;
100        }
101        out.push(TestAssertionSmell {
102            file: file.clone(),
103            enclosing_fq_name: symbol.clone(),
104            assertion_kind: assertion.kind.clone(),
105            score: assertion.score,
106            assertion_count,
107            reasons: vec![assertion.reason.clone()],
108            excerpt: assertion.excerpt.clone(),
109            start_byte: start_byte + assertion.start_byte,
110        });
111    }
112
113    if assertions.iter().all(|assertion| assertion.shallow) {
114        out.push(TestAssertionSmell {
115            file: file.clone(),
116            enclosing_fq_name: symbol,
117            assertion_kind: "shallow-assertions-only".to_string(),
118            score: weights.shallow_assertion_only_weight,
119            assertion_count,
120            reasons: vec!["shallow-assertions-only".to_string()],
121            excerpt: compact_cpp_excerpt(body),
122            start_byte,
123        });
124    }
125}
126
127fn collect_cpp_assertions(body: &str, weights: &TestAssertionWeights) -> Vec<CppAssertionSignal> {
128    let mut assertions = Vec::new();
129
130    for captures in CPP_ASSERT_TRUTH_RE.captures_iter(body) {
131        let whole = captures.get(0).expect("whole match");
132        let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
133        let arg = normalize_cpp_expr(captures.name("arg").map(|m| m.as_str()).unwrap_or(""));
134        let (kind, score, shallow) = match matcher {
135            "TRUE" if arg == "true" => ("constant-truth", weights.constant_truth_weight, true),
136            "FALSE" if arg == "false" => ("constant-truth", weights.constant_truth_weight, true),
137            _ => ("meaningful-assertion", 0, false),
138        };
139        assertions.push(CppAssertionSignal {
140            kind: kind.to_string(),
141            score,
142            shallow,
143            reason: kind.to_string(),
144            excerpt: compact_cpp_excerpt(whole.as_str()),
145            start_byte: whole.start(),
146        });
147    }
148
149    for captures in CPP_ASSERT_EQUALITY_RE.captures_iter(body) {
150        let whole = captures.get(0).expect("whole match");
151        let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
152        let left = normalize_cpp_expr(captures.name("left").map(|m| m.as_str()).unwrap_or(""));
153        let right = normalize_cpp_expr(captures.name("right").map(|m| m.as_str()).unwrap_or(""));
154        let signal = if matcher == "EQ" && left == right {
155            let (kind, reason, score) = if is_cpp_literal(&left) {
156                (
157                    "constant-equality",
158                    "constant-equality",
159                    weights.constant_equality_weight,
160                )
161            } else {
162                (
163                    "self-comparison",
164                    "self-comparison",
165                    weights.tautological_assertion_weight,
166                )
167            };
168            CppAssertionSignal {
169                kind: kind.to_string(),
170                score,
171                shallow: false,
172                reason: reason.to_string(),
173                excerpt: compact_cpp_excerpt(whole.as_str()),
174                start_byte: whole.start(),
175            }
176        } else if matcher == "NE" && (is_cpp_null_literal(&left) || is_cpp_null_literal(&right)) {
177            CppAssertionSignal {
178                kind: "nullness-only".to_string(),
179                score: weights.nullness_only_weight,
180                shallow: true,
181                reason: "nullness-only".to_string(),
182                excerpt: compact_cpp_excerpt(whole.as_str()),
183                start_byte: whole.start(),
184            }
185        } else {
186            CppAssertionSignal {
187                kind: "meaningful-assertion".to_string(),
188                score: 0,
189                shallow: false,
190                reason: "meaningful-assertion".to_string(),
191                excerpt: compact_cpp_excerpt(whole.as_str()),
192                start_byte: whole.start(),
193            }
194        };
195        assertions.push(signal);
196    }
197
198    assertions
199}
200
201pub fn cpp_contains_tests(source: &str) -> bool {
202    [
203        &*CPP_GTEST_TEST_START_RE,
204        &*CPP_CATCH2_TEST_START_RE,
205        &*CPP_BOOST_TEST_START_RE,
206        &*CPP_MSTEST_METHOD_START_RE,
207    ]
208    .iter()
209    .any(|regex| regex.is_match(source))
210}
211
212fn normalize_cpp_expr(expr: &str) -> String {
213    expr.trim()
214        .trim_end_matches(';')
215        .trim_matches(|ch| matches!(ch, '(' | ')' | ' '))
216        .split_whitespace()
217        .collect::<Vec<_>>()
218        .join(" ")
219}
220
221fn is_cpp_literal(expr: &str) -> bool {
222    let trimmed = expr.trim();
223    (trimmed.starts_with('"') && trimmed.ends_with('"'))
224        || matches!(trimmed, "true" | "false" | "nullptr" | "NULL")
225        || trimmed.parse::<i64>().is_ok()
226        || trimmed.parse::<f64>().is_ok()
227}
228
229fn is_cpp_null_literal(expr: &str) -> bool {
230    matches!(expr.trim(), "nullptr" | "NULL")
231}
232
233fn compact_cpp_excerpt(text: &str) -> String {
234    text.split_whitespace().collect::<Vec<_>>().join(" ")
235}
236
237fn extract_braced_body(source: &str, open_brace_index: usize) -> Option<(&str, usize)> {
238    let mut depth = 0usize;
239    let mut body_start = None;
240    for (offset, ch) in source[open_brace_index..].char_indices() {
241        let absolute = open_brace_index + offset;
242        match ch {
243            '{' => {
244                depth += 1;
245                if depth == 1 {
246                    body_start = Some(absolute + ch.len_utf8());
247                }
248            }
249            '}' => {
250                if depth == 1 {
251                    let start = body_start?;
252                    return Some((&source[start..absolute], start));
253                }
254                depth = depth.saturating_sub(1);
255            }
256            _ => {}
257        }
258    }
259    None
260}