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 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
58pub 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
84fn 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 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 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}