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