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 and
6//! its syntax tree. The analyzer passes its prepared tree, so a report does not
7//! reparse a file that another request already parsed.
8//!
9//! C++ recognizes the four framework macro families: `TEST`/`TEST_F`/`TEST_P`/`TYPED_TEST` (gtest),
10//! `TEST_CASE`/`SCENARIO` (Catch2), `BOOST_AUTO_TEST_CASE` and `TEST_METHOD`
11//! (MSTest). Assertion smells use their parsed function bodies.
12
13use brokk_bifrost_core::analyzer::ProjectFile;
14use brokk_bifrost_core::analyzer::common::collapse_whitespace;
15use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
16use brokk_bifrost_core::analyzer::test_assertions::{
17    TestAssertionSignal, append_test_assertion_findings, is_common_assertion_literal,
18    normalize_assertion_expression,
19};
20use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_tree_preorder};
21use brokk_bifrost_core::path_utils::rel_path_string;
22use regex::Regex;
23use std::sync::LazyLock;
24
25static CPP_GTEST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(r#"(?s)\b(?:TEST|TEST_F|TEST_P|TYPED_TEST)\b\s*(?:/\*.*?\*/\s*)?\([^)]*\)\s*\{"#)
27        .expect("valid regex")
28});
29static CPP_CATCH2_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r#"(?s)\b(?:TEST_CASE|SCENARIO)\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
31});
32static CPP_BOOST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
33    Regex::new(r#"(?s)\bBOOST_AUTO_TEST_CASE\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
34});
35static CPP_MSTEST_METHOD_START_RE: LazyLock<Regex> =
36    LazyLock::new(|| Regex::new(r#"(?s)\bTEST_METHOD\b\s*\([^)]*\)\s*\{"#).expect("valid regex"));
37static CPP_ASSERT_TRUTH_RE: LazyLock<Regex> = LazyLock::new(|| {
38    Regex::new(r#"(?:EXPECT|ASSERT)_(?P<matcher>TRUE|FALSE)\s*\((?P<arg>[^)\n]+)\)"#)
39        .expect("valid regex")
40});
41static CPP_ASSERT_EQUALITY_RE: LazyLock<Regex> = LazyLock::new(|| {
42    Regex::new(
43        r#"(?:EXPECT|ASSERT)_(?P<matcher>EQ|NE)\s*\((?P<left>[^,\n]+?)\s*,\s*(?P<right>[^)\n]+)\)"#,
44    )
45    .expect("valid regex")
46});
47
48#[derive(Clone)]
49struct CppAssertionSignal {
50    kind: String,
51    score: i32,
52    shallow: bool,
53    reason: String,
54    excerpt: String,
55    start_byte: usize,
56}
57
58/// `tree` must be the C++ syntax tree of exactly `source`.
59pub fn detect_cpp_test_assertion_smells(
60    file: &ProjectFile,
61    source: &str,
62    tree: &tree_sitter::Tree,
63    weights: &TestAssertionWeights,
64) -> Vec<TestAssertionSmell> {
65    let mut findings = Vec::new();
66    walk_tree_preorder(tree.root_node(), true, |node| {
67        if let Some(body) = cpp_test_body(node, source)
68            && body.kind() == "compound_statement"
69            && let Some(open) = body.child(0)
70            && let Some(close) = body.child(body.child_count() - 1)
71            && open.kind() == "{"
72            && close.kind() == "}"
73            && !close.is_missing()
74        {
75            let start = open.end_byte();
76            let end = close.start_byte();
77            analyze_cpp_test_case(file, &source[start..end], start, weights, &mut findings);
78        }
79        WalkControl::Continue
80    });
81    findings
82}
83
84/// Identifier arguments parse as function declarators. String arguments (as
85/// used by Catch2) parse as a call without a semicolon followed by a block.
86/// Both routes select the complete compound statement from the syntax tree.
87fn cpp_test_body<'tree>(
88    node: tree_sitter::Node<'tree>,
89    source: &str,
90) -> Option<tree_sitter::Node<'tree>> {
91    let (declarator, body) = match node.kind() {
92        "function_definition" => (
93            node.child_by_field_name("declarator")?,
94            node.child_by_field_name("body")?,
95        ),
96        "compound_statement" => {
97            let mut previous = node.prev_named_sibling()?;
98            while previous.is_extra() {
99                previous = previous.prev_named_sibling()?;
100            }
101            if previous.kind() != "expression_statement" {
102                return None;
103            }
104            let terminator = previous.child(previous.child_count() - 1)?;
105            if terminator.kind() != ";" || !terminator.is_missing() {
106                return None;
107            }
108            let call = previous.named_child(0)?;
109            if call.kind() != "call_expression" {
110                return None;
111            }
112            (call.child_by_field_name("function")?, node)
113        }
114        _ => return None,
115    };
116    let name = crate::structural::declarator_name_node(declarator)?;
117    matches!(
118        name.utf8_text(source.as_bytes()).expect("source node"),
119        "TEST"
120            | "TEST_F"
121            | "TEST_P"
122            | "TYPED_TEST"
123            | "TEST_CASE"
124            | "SCENARIO"
125            | "BOOST_AUTO_TEST_CASE"
126            | "TEST_METHOD"
127    )
128    .then_some(body)
129}
130
131fn analyze_cpp_test_case(
132    file: &ProjectFile,
133    body: &str,
134    start_byte: usize,
135    weights: &TestAssertionWeights,
136    out: &mut Vec<TestAssertionSmell>,
137) {
138    // C++ test macros declare no named code unit, so the file is the symbol.
139    // Render it slash-separated: `ProjectFile`'s `Display` uses the OS
140    // separator, which made the report differ on Windows.
141    let symbol = rel_path_string(file);
142    let assertions: Vec<_> = collect_cpp_assertions(body, weights)
143        .into_iter()
144        .map(|assertion| TestAssertionSignal {
145            kind: assertion.kind,
146            score: assertion.score,
147            shallow: assertion.shallow,
148            meaningful: false,
149            reasons: vec![assertion.reason],
150            excerpt: assertion.excerpt,
151            start_byte: start_byte + assertion.start_byte,
152        })
153        .collect();
154    append_test_assertion_findings(
155        file,
156        symbol,
157        collapse_whitespace(body),
158        start_byte,
159        &assertions,
160        weights,
161        out,
162    );
163}
164
165fn collect_cpp_assertions(body: &str, weights: &TestAssertionWeights) -> Vec<CppAssertionSignal> {
166    let mut assertions = Vec::new();
167
168    for captures in CPP_ASSERT_TRUTH_RE.captures_iter(body) {
169        let whole = captures.get(0).expect("whole match");
170        let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
171        let arg = normalize_cpp_expr(captures.name("arg").map(|m| m.as_str()).unwrap_or(""));
172        let (kind, score, shallow) = match matcher {
173            "TRUE" if arg == "true" => ("constant-truth", weights.constant_truth_weight, true),
174            "FALSE" if arg == "false" => ("constant-truth", weights.constant_truth_weight, true),
175            _ => ("meaningful-assertion", 0, false),
176        };
177        assertions.push(CppAssertionSignal {
178            kind: kind.to_string(),
179            score,
180            shallow,
181            reason: kind.to_string(),
182            excerpt: collapse_whitespace(whole.as_str()),
183            start_byte: whole.start(),
184        });
185    }
186
187    for captures in CPP_ASSERT_EQUALITY_RE.captures_iter(body) {
188        let whole = captures.get(0).expect("whole match");
189        let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
190        let left = normalize_cpp_expr(captures.name("left").map(|m| m.as_str()).unwrap_or(""));
191        let right = normalize_cpp_expr(captures.name("right").map(|m| m.as_str()).unwrap_or(""));
192        let signal = if matcher == "EQ" && left == right {
193            let (kind, reason, score) = if is_cpp_literal(&left) {
194                (
195                    "constant-equality",
196                    "constant-equality",
197                    weights.constant_equality_weight,
198                )
199            } else {
200                (
201                    "self-comparison",
202                    "self-comparison",
203                    weights.tautological_assertion_weight,
204                )
205            };
206            CppAssertionSignal {
207                kind: kind.to_string(),
208                score,
209                shallow: false,
210                reason: reason.to_string(),
211                excerpt: collapse_whitespace(whole.as_str()),
212                start_byte: whole.start(),
213            }
214        } else if matcher == "NE" && (is_cpp_null_literal(&left) || is_cpp_null_literal(&right)) {
215            CppAssertionSignal {
216                kind: "nullness-only".to_string(),
217                score: weights.nullness_only_weight,
218                shallow: true,
219                reason: "nullness-only".to_string(),
220                excerpt: collapse_whitespace(whole.as_str()),
221                start_byte: whole.start(),
222            }
223        } else {
224            CppAssertionSignal {
225                kind: "meaningful-assertion".to_string(),
226                score: 0,
227                shallow: false,
228                reason: "meaningful-assertion".to_string(),
229                excerpt: collapse_whitespace(whole.as_str()),
230                start_byte: whole.start(),
231            }
232        };
233        assertions.push(signal);
234    }
235
236    assertions
237}
238
239pub fn cpp_contains_tests(source: &str) -> bool {
240    [
241        &*CPP_GTEST_TEST_START_RE,
242        &*CPP_CATCH2_TEST_START_RE,
243        &*CPP_BOOST_TEST_START_RE,
244        &*CPP_MSTEST_METHOD_START_RE,
245    ]
246    .iter()
247    .any(|regex| regex.is_match(source))
248}
249
250fn normalize_cpp_expr(expr: &str) -> String {
251    normalize_assertion_expression(expr.trim().trim_end_matches(';'))
252}
253
254fn is_cpp_literal(expr: &str) -> bool {
255    let trimmed = expr.trim();
256    is_common_assertion_literal(trimmed) || matches!(trimmed, "true" | "false" | "nullptr" | "NULL")
257}
258
259fn is_cpp_null_literal(expr: &str) -> bool {
260    matches!(expr.trim(), "nullptr" | "NULL")
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn detect(
268        file: &ProjectFile,
269        source: &str,
270        weights: &TestAssertionWeights,
271    ) -> Vec<TestAssertionSmell> {
272        let mut parser = tree_sitter::Parser::new();
273        parser
274            .set_language(&tree_sitter_cpp::LANGUAGE.into())
275            .expect("C++ grammar");
276        let tree = parser.parse(source, None).expect("C++ parse");
277        detect_cpp_test_assertion_smells(file, source, &tree, weights)
278    }
279
280    #[test]
281    #[brokk_bifrost_test_macros::hardwon(
282        "e3c20990a105: quicksilver round 4 on brokk-bifrost-cpp: string and comment braces truncated test bodies"
283    )]
284    fn braces_in_literals_and_comments_do_not_hide_assertions() {
285        let file = ProjectFile::new(std::env::current_dir().unwrap(), "braces.cpp");
286        for framework in [
287            "TEST(Suite, Case)",
288            "TEST_F(Suite, Case)",
289            "TEST_P(Suite, Case)",
290            "TYPED_TEST(Suite, Case)",
291            r#"TEST_CASE("case")"#,
292            r#"SCENARIO("case")"#,
293            "BOOST_AUTO_TEST_CASE(case)",
294            "TEST_METHOD(case)",
295        ] {
296            for prefix in [r#"const char *s = "}";"#, "// }\n", "/* } */"] {
297                let source = format!("{framework} {{ {prefix} EXPECT_EQ(actual, expected); }}");
298                assert!(
299                    detect(&file, &source, &TestAssertionWeights::defaults()).is_empty(),
300                    "{source}"
301                );
302                // Prove recognition ran, rather than accepting an empty result
303                // from an unsupported macro shape.
304                let missing = format!("{framework} {{ {prefix} do_work(); }}");
305                assert!(
306                    !detect(&file, &missing, &TestAssertionWeights::defaults()).is_empty(),
307                    "{missing}"
308                );
309            }
310        }
311    }
312
313    #[test]
314    fn nonpositive_shallow_only_score_emits_no_finding() {
315        let file = ProjectFile::new(
316            std::env::current_dir().expect("test working directory"),
317            "shallow-only.cpp",
318        );
319        let mut weights = TestAssertionWeights::defaults();
320        weights.constant_truth_weight = 0;
321        weights.shallow_assertion_only_weight = 0;
322
323        let findings = detect(&file, "TEST(Suite, case) { EXPECT_TRUE(true); }", &weights);
324
325        assert!(findings.is_empty(), "unexpected findings: {findings:?}");
326    }
327}