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::common::collapse_whitespace;
14use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
15use brokk_bifrost_core::analyzer::test_assertions::{
16    TestAssertionSignal, append_test_assertion_findings, is_common_assertion_literal,
17    normalize_assertion_expression,
18};
19use regex::Regex;
20use std::sync::LazyLock;
21
22static CPP_GTEST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(r#"(?s)\b(?:TEST|TEST_F|TEST_P|TYPED_TEST)\b\s*(?:/\*.*?\*/\s*)?\([^)]*\)\s*\{"#)
24        .expect("valid regex")
25});
26static CPP_CATCH2_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
27    Regex::new(r#"(?s)\b(?:TEST_CASE|SCENARIO)\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
28});
29static CPP_BOOST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r#"(?s)\bBOOST_AUTO_TEST_CASE\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
31});
32static CPP_MSTEST_METHOD_START_RE: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r#"(?s)\bTEST_METHOD\b\s*\([^)]*\)\s*\{"#).expect("valid regex"));
34static CPP_ASSERT_TRUTH_RE: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(r#"(?:EXPECT|ASSERT)_(?P<matcher>TRUE|FALSE)\s*\((?P<arg>[^)\n]+)\)"#)
36        .expect("valid regex")
37});
38static CPP_ASSERT_EQUALITY_RE: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(
40        r#"(?:EXPECT|ASSERT)_(?P<matcher>EQ|NE)\s*\((?P<left>[^,\n]+?)\s*,\s*(?P<right>[^)\n]+)\)"#,
41    )
42    .expect("valid regex")
43});
44
45#[derive(Clone)]
46struct CppAssertionSignal {
47    kind: String,
48    score: i32,
49    shallow: bool,
50    reason: String,
51    excerpt: String,
52    start_byte: usize,
53}
54
55pub fn detect_cpp_test_assertion_smells(
56    file: &ProjectFile,
57    source: &str,
58    weights: &TestAssertionWeights,
59) -> Vec<TestAssertionSmell> {
60    let mut findings = Vec::new();
61    for regex in [
62        &*CPP_GTEST_TEST_START_RE,
63        &*CPP_CATCH2_TEST_START_RE,
64        &*CPP_BOOST_TEST_START_RE,
65        &*CPP_MSTEST_METHOD_START_RE,
66    ] {
67        for whole in regex.find_iter(source) {
68            let Some((body, body_start)) = extract_braced_body(source, whole.end() - 1) else {
69                continue;
70            };
71            analyze_cpp_test_case(file, body, body_start, weights, &mut findings);
72        }
73    }
74    findings
75}
76
77fn analyze_cpp_test_case(
78    file: &ProjectFile,
79    body: &str,
80    start_byte: usize,
81    weights: &TestAssertionWeights,
82    out: &mut Vec<TestAssertionSmell>,
83) {
84    let symbol = file.to_string();
85    let assertions: Vec<_> = collect_cpp_assertions(body, weights)
86        .into_iter()
87        .map(|assertion| TestAssertionSignal {
88            kind: assertion.kind,
89            score: assertion.score,
90            shallow: assertion.shallow,
91            meaningful: false,
92            reasons: vec![assertion.reason],
93            excerpt: assertion.excerpt,
94            start_byte: start_byte + assertion.start_byte,
95        })
96        .collect();
97    append_test_assertion_findings(
98        file,
99        symbol,
100        collapse_whitespace(body),
101        start_byte,
102        &assertions,
103        weights,
104        out,
105    );
106}
107
108fn collect_cpp_assertions(body: &str, weights: &TestAssertionWeights) -> Vec<CppAssertionSignal> {
109    let mut assertions = Vec::new();
110
111    for captures in CPP_ASSERT_TRUTH_RE.captures_iter(body) {
112        let whole = captures.get(0).expect("whole match");
113        let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
114        let arg = normalize_cpp_expr(captures.name("arg").map(|m| m.as_str()).unwrap_or(""));
115        let (kind, score, shallow) = match matcher {
116            "TRUE" if arg == "true" => ("constant-truth", weights.constant_truth_weight, true),
117            "FALSE" if arg == "false" => ("constant-truth", weights.constant_truth_weight, true),
118            _ => ("meaningful-assertion", 0, false),
119        };
120        assertions.push(CppAssertionSignal {
121            kind: kind.to_string(),
122            score,
123            shallow,
124            reason: kind.to_string(),
125            excerpt: collapse_whitespace(whole.as_str()),
126            start_byte: whole.start(),
127        });
128    }
129
130    for captures in CPP_ASSERT_EQUALITY_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 left = normalize_cpp_expr(captures.name("left").map(|m| m.as_str()).unwrap_or(""));
134        let right = normalize_cpp_expr(captures.name("right").map(|m| m.as_str()).unwrap_or(""));
135        let signal = if matcher == "EQ" && left == right {
136            let (kind, reason, score) = if is_cpp_literal(&left) {
137                (
138                    "constant-equality",
139                    "constant-equality",
140                    weights.constant_equality_weight,
141                )
142            } else {
143                (
144                    "self-comparison",
145                    "self-comparison",
146                    weights.tautological_assertion_weight,
147                )
148            };
149            CppAssertionSignal {
150                kind: kind.to_string(),
151                score,
152                shallow: false,
153                reason: reason.to_string(),
154                excerpt: collapse_whitespace(whole.as_str()),
155                start_byte: whole.start(),
156            }
157        } else if matcher == "NE" && (is_cpp_null_literal(&left) || is_cpp_null_literal(&right)) {
158            CppAssertionSignal {
159                kind: "nullness-only".to_string(),
160                score: weights.nullness_only_weight,
161                shallow: true,
162                reason: "nullness-only".to_string(),
163                excerpt: collapse_whitespace(whole.as_str()),
164                start_byte: whole.start(),
165            }
166        } else {
167            CppAssertionSignal {
168                kind: "meaningful-assertion".to_string(),
169                score: 0,
170                shallow: false,
171                reason: "meaningful-assertion".to_string(),
172                excerpt: collapse_whitespace(whole.as_str()),
173                start_byte: whole.start(),
174            }
175        };
176        assertions.push(signal);
177    }
178
179    assertions
180}
181
182pub fn cpp_contains_tests(source: &str) -> bool {
183    [
184        &*CPP_GTEST_TEST_START_RE,
185        &*CPP_CATCH2_TEST_START_RE,
186        &*CPP_BOOST_TEST_START_RE,
187        &*CPP_MSTEST_METHOD_START_RE,
188    ]
189    .iter()
190    .any(|regex| regex.is_match(source))
191}
192
193fn normalize_cpp_expr(expr: &str) -> String {
194    normalize_assertion_expression(expr.trim().trim_end_matches(';'))
195}
196
197fn is_cpp_literal(expr: &str) -> bool {
198    let trimmed = expr.trim();
199    is_common_assertion_literal(trimmed) || matches!(trimmed, "true" | "false" | "nullptr" | "NULL")
200}
201
202fn is_cpp_null_literal(expr: &str) -> bool {
203    matches!(expr.trim(), "nullptr" | "NULL")
204}
205
206fn extract_braced_body(source: &str, open_brace_index: usize) -> Option<(&str, usize)> {
207    let mut depth = 0usize;
208    let mut body_start = None;
209    for (offset, ch) in source[open_brace_index..].char_indices() {
210        let absolute = open_brace_index + offset;
211        match ch {
212            '{' => {
213                depth += 1;
214                if depth == 1 {
215                    body_start = Some(absolute + ch.len_utf8());
216                }
217            }
218            '}' => {
219                if depth == 1 {
220                    let start = body_start?;
221                    return Some((&source[start..absolute], start));
222                }
223                depth = depth.saturating_sub(1);
224            }
225            _ => {}
226        }
227    }
228    None
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn nonpositive_shallow_only_score_emits_no_finding() {
237        let file = ProjectFile::new(
238            std::env::current_dir().expect("test working directory"),
239            "shallow-only.cpp",
240        );
241        let mut weights = TestAssertionWeights::defaults();
242        weights.constant_truth_weight = 0;
243        weights.shallow_assertion_only_weight = 0;
244
245        let findings = detect_cpp_test_assertion_smells(
246            &file,
247            "TEST(Suite, case) { EXPECT_TRUE(true); }",
248            &weights,
249        );
250
251        assert!(findings.is_empty(), "unexpected findings: {findings:?}");
252    }
253}