1use 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
57pub 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
83fn 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 "e3c20990a105: 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 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}