1use brokk_bifrost_core::analyzer::ProjectFile;
13use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
14use regex::Regex;
15use std::sync::LazyLock;
16
17static CPP_GTEST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
18 Regex::new(r#"(?s)\b(?:TEST|TEST_F|TEST_P|TYPED_TEST)\b\s*(?:/\*.*?\*/\s*)?\([^)]*\)\s*\{"#)
19 .expect("valid regex")
20});
21static CPP_CATCH2_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r#"(?s)\b(?:TEST_CASE|SCENARIO)\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
23});
24static CPP_BOOST_TEST_START_RE: LazyLock<Regex> = LazyLock::new(|| {
25 Regex::new(r#"(?s)\bBOOST_AUTO_TEST_CASE\b\s*\([^)]*\)\s*\{"#).expect("valid regex")
26});
27static CPP_MSTEST_METHOD_START_RE: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r#"(?s)\bTEST_METHOD\b\s*\([^)]*\)\s*\{"#).expect("valid regex"));
29static CPP_ASSERT_TRUTH_RE: LazyLock<Regex> = LazyLock::new(|| {
30 Regex::new(r#"(?:EXPECT|ASSERT)_(?P<matcher>TRUE|FALSE)\s*\((?P<arg>[^)\n]+)\)"#)
31 .expect("valid regex")
32});
33static CPP_ASSERT_EQUALITY_RE: LazyLock<Regex> = LazyLock::new(|| {
34 Regex::new(
35 r#"(?:EXPECT|ASSERT)_(?P<matcher>EQ|NE)\s*\((?P<left>[^,\n]+?)\s*,\s*(?P<right>[^)\n]+)\)"#,
36 )
37 .expect("valid regex")
38});
39
40#[derive(Clone)]
41struct CppAssertionSignal {
42 kind: String,
43 score: i32,
44 shallow: bool,
45 reason: String,
46 excerpt: String,
47 start_byte: usize,
48}
49
50pub fn detect_cpp_test_assertion_smells(
51 file: &ProjectFile,
52 source: &str,
53 weights: &TestAssertionWeights,
54) -> Vec<TestAssertionSmell> {
55 let mut findings = Vec::new();
56 for regex in [
57 &*CPP_GTEST_TEST_START_RE,
58 &*CPP_CATCH2_TEST_START_RE,
59 &*CPP_BOOST_TEST_START_RE,
60 &*CPP_MSTEST_METHOD_START_RE,
61 ] {
62 for whole in regex.find_iter(source) {
63 let Some((body, body_start)) = extract_braced_body(source, whole.end() - 1) else {
64 continue;
65 };
66 analyze_cpp_test_case(file, body, body_start, weights, &mut findings);
67 }
68 }
69 findings
70}
71
72fn analyze_cpp_test_case(
73 file: &ProjectFile,
74 body: &str,
75 start_byte: usize,
76 weights: &TestAssertionWeights,
77 out: &mut Vec<TestAssertionSmell>,
78) {
79 let assertions = collect_cpp_assertions(body, weights);
80 let assertion_count = assertions.len() as i32;
81 let symbol = file.to_string();
82
83 if assertion_count == 0 {
84 out.push(TestAssertionSmell {
85 file: file.clone(),
86 enclosing_fq_name: symbol,
87 assertion_kind: "no-assertions".to_string(),
88 score: weights.no_assertion_weight,
89 assertion_count: 0,
90 reasons: vec!["no-assertions".to_string()],
91 excerpt: compact_cpp_excerpt(body),
92 start_byte,
93 });
94 return;
95 }
96
97 for assertion in &assertions {
98 if assertion.score <= 0 {
99 continue;
100 }
101 out.push(TestAssertionSmell {
102 file: file.clone(),
103 enclosing_fq_name: symbol.clone(),
104 assertion_kind: assertion.kind.clone(),
105 score: assertion.score,
106 assertion_count,
107 reasons: vec![assertion.reason.clone()],
108 excerpt: assertion.excerpt.clone(),
109 start_byte: start_byte + assertion.start_byte,
110 });
111 }
112
113 if assertions.iter().all(|assertion| assertion.shallow) {
114 out.push(TestAssertionSmell {
115 file: file.clone(),
116 enclosing_fq_name: symbol,
117 assertion_kind: "shallow-assertions-only".to_string(),
118 score: weights.shallow_assertion_only_weight,
119 assertion_count,
120 reasons: vec!["shallow-assertions-only".to_string()],
121 excerpt: compact_cpp_excerpt(body),
122 start_byte,
123 });
124 }
125}
126
127fn collect_cpp_assertions(body: &str, weights: &TestAssertionWeights) -> Vec<CppAssertionSignal> {
128 let mut assertions = Vec::new();
129
130 for captures in CPP_ASSERT_TRUTH_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 arg = normalize_cpp_expr(captures.name("arg").map(|m| m.as_str()).unwrap_or(""));
134 let (kind, score, shallow) = match matcher {
135 "TRUE" if arg == "true" => ("constant-truth", weights.constant_truth_weight, true),
136 "FALSE" if arg == "false" => ("constant-truth", weights.constant_truth_weight, true),
137 _ => ("meaningful-assertion", 0, false),
138 };
139 assertions.push(CppAssertionSignal {
140 kind: kind.to_string(),
141 score,
142 shallow,
143 reason: kind.to_string(),
144 excerpt: compact_cpp_excerpt(whole.as_str()),
145 start_byte: whole.start(),
146 });
147 }
148
149 for captures in CPP_ASSERT_EQUALITY_RE.captures_iter(body) {
150 let whole = captures.get(0).expect("whole match");
151 let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
152 let left = normalize_cpp_expr(captures.name("left").map(|m| m.as_str()).unwrap_or(""));
153 let right = normalize_cpp_expr(captures.name("right").map(|m| m.as_str()).unwrap_or(""));
154 let signal = if matcher == "EQ" && left == right {
155 let (kind, reason, score) = if is_cpp_literal(&left) {
156 (
157 "constant-equality",
158 "constant-equality",
159 weights.constant_equality_weight,
160 )
161 } else {
162 (
163 "self-comparison",
164 "self-comparison",
165 weights.tautological_assertion_weight,
166 )
167 };
168 CppAssertionSignal {
169 kind: kind.to_string(),
170 score,
171 shallow: false,
172 reason: reason.to_string(),
173 excerpt: compact_cpp_excerpt(whole.as_str()),
174 start_byte: whole.start(),
175 }
176 } else if matcher == "NE" && (is_cpp_null_literal(&left) || is_cpp_null_literal(&right)) {
177 CppAssertionSignal {
178 kind: "nullness-only".to_string(),
179 score: weights.nullness_only_weight,
180 shallow: true,
181 reason: "nullness-only".to_string(),
182 excerpt: compact_cpp_excerpt(whole.as_str()),
183 start_byte: whole.start(),
184 }
185 } else {
186 CppAssertionSignal {
187 kind: "meaningful-assertion".to_string(),
188 score: 0,
189 shallow: false,
190 reason: "meaningful-assertion".to_string(),
191 excerpt: compact_cpp_excerpt(whole.as_str()),
192 start_byte: whole.start(),
193 }
194 };
195 assertions.push(signal);
196 }
197
198 assertions
199}
200
201pub fn cpp_contains_tests(source: &str) -> bool {
202 [
203 &*CPP_GTEST_TEST_START_RE,
204 &*CPP_CATCH2_TEST_START_RE,
205 &*CPP_BOOST_TEST_START_RE,
206 &*CPP_MSTEST_METHOD_START_RE,
207 ]
208 .iter()
209 .any(|regex| regex.is_match(source))
210}
211
212fn normalize_cpp_expr(expr: &str) -> String {
213 expr.trim()
214 .trim_end_matches(';')
215 .trim_matches(|ch| matches!(ch, '(' | ')' | ' '))
216 .split_whitespace()
217 .collect::<Vec<_>>()
218 .join(" ")
219}
220
221fn is_cpp_literal(expr: &str) -> bool {
222 let trimmed = expr.trim();
223 (trimmed.starts_with('"') && trimmed.ends_with('"'))
224 || matches!(trimmed, "true" | "false" | "nullptr" | "NULL")
225 || trimmed.parse::<i64>().is_ok()
226 || trimmed.parse::<f64>().is_ok()
227}
228
229fn is_cpp_null_literal(expr: &str) -> bool {
230 matches!(expr.trim(), "nullptr" | "NULL")
231}
232
233fn compact_cpp_excerpt(text: &str) -> String {
234 text.split_whitespace().collect::<Vec<_>>().join(" ")
235}
236
237fn extract_braced_body(source: &str, open_brace_index: usize) -> Option<(&str, usize)> {
238 let mut depth = 0usize;
239 let mut body_start = None;
240 for (offset, ch) in source[open_brace_index..].char_indices() {
241 let absolute = open_brace_index + offset;
242 match ch {
243 '{' => {
244 depth += 1;
245 if depth == 1 {
246 body_start = Some(absolute + ch.len_utf8());
247 }
248 }
249 '}' => {
250 if depth == 1 {
251 let start = body_start?;
252 return Some((&source[start..absolute], start));
253 }
254 depth = depth.saturating_sub(1);
255 }
256 _ => {}
257 }
258 }
259 None
260}