1use 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}