1use crate::declarations::py_node_text;
2use brokk_bifrost_core::analyzer::ProjectFile;
3use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
4use brokk_bifrost_core::analyzer::test_assertions::{
5 CommonTestAssertionKind, TestAssertionSignal, append_test_assertion_findings,
6 classify_common_test_assertion, compact_assertion_excerpt, is_common_assertion_literal,
7 normalize_assertion_expression, oversized_quoted_literal,
8};
9use regex::Regex;
10use std::sync::LazyLock;
11use tree_sitter::{Node, Parser};
12
13const TEST_ASSERTION_EXCERPT_MAX_LEN: usize = 180;
14
15static PY_ASSERT_EQUALITY_RE: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(r#"self\.assert(?:Equal|Is)\s*\((?P<left>[^,\n]+?)\s*,\s*(?P<right>[^,\n\)]+)"#)
17 .expect("valid regex")
18});
19static PY_ASSERT_TRUTH_RE: LazyLock<Regex> = LazyLock::new(|| {
20 Regex::new(r#"self\.assert(?P<matcher>True|False|IsNone|IsNotNone)\s*\((?P<arg>[^,\n\)]+)"#)
21 .expect("valid regex")
22});
23static PY_BARE_ASSERT_RE: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r#"(?m)^\s*assert\s+(?P<expr>[^\n]+)"#).expect("valid regex"));
25static PY_RAISES_RE: LazyLock<Regex> = LazyLock::new(|| {
26 Regex::new(r#"pytest\.raises\s*\(|self\.assertRaises\s*\("#).expect("valid regex")
27});
28static PY_VERIFY_RE: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r#"\.\s*assert_(?:called|called_once|called_once_with|called_with|not_called)\s*\("#)
30 .expect("valid regex")
31});
32
33#[derive(Clone)]
34struct PythonTestCase {
35 name: String,
36 body: String,
37 start_byte: usize,
38}
39
40pub fn detect_python_test_assertion_smells(
41 file: &ProjectFile,
42 source: &str,
43 weights: &TestAssertionWeights,
44) -> Vec<TestAssertionSmell> {
45 let mut parser = Parser::new();
46 parser
47 .set_language(&tree_sitter_python::LANGUAGE.into())
48 .expect("failed to load python parser");
49 let Some(tree) = parser.parse(source, None) else {
50 return Vec::new();
51 };
52
53 let mut test_cases = Vec::new();
54 collect_python_test_cases(tree.root_node(), source, &mut test_cases);
55
56 let mut findings = Vec::new();
57 for test_case in test_cases {
58 analyze_python_test_case(file, &test_case, weights, &mut findings);
59 }
60 findings
61}
62
63fn collect_python_test_cases(node: Node<'_>, source: &str, out: &mut Vec<PythonTestCase>) {
64 match node.kind() {
65 "function_definition" => {
66 if python_function_has_fixture_decorator(node, source) {
67 return;
68 }
69 if let Some(name_node) = node.child_by_field_name("name") {
70 let name = py_node_text(name_node, source).trim().to_string();
71 if name.starts_with("test_") {
72 let body = node
73 .child_by_field_name("body")
74 .map(|body| py_node_text(body, source).to_string())
75 .unwrap_or_else(|| py_node_text(node, source).to_string());
76 out.push(PythonTestCase {
77 name,
78 body,
79 start_byte: node.start_byte(),
80 });
81 }
82 }
83 }
84 "decorated_definition" => {
85 if python_decorated_definition_has_fixture(node, source) {
86 return;
87 }
88 let has_pytest_mark = py_node_text(node, source).contains("pytest.mark");
89 if has_pytest_mark
90 && let Some(definition) = node.child_by_field_name("definition")
91 && definition.kind() == "function_definition"
92 {
93 let name = definition
94 .child_by_field_name("name")
95 .map(|name_node| py_node_text(name_node, source).trim().to_string())
96 .unwrap_or_else(|| "anonymous".to_string());
97 let body = definition
98 .child_by_field_name("body")
99 .map(|body| py_node_text(body, source).to_string())
100 .unwrap_or_else(|| py_node_text(definition, source).to_string());
101 out.push(PythonTestCase {
102 name,
103 body,
104 start_byte: node.start_byte(),
105 });
106 }
107 }
108 _ => {}
109 }
110
111 let mut cursor = node.walk();
112 for child in node.named_children(&mut cursor) {
113 collect_python_test_cases(child, source, out);
114 }
115}
116
117fn python_function_has_fixture_decorator(function: Node<'_>, source: &str) -> bool {
118 let Some(parent) = function.parent() else {
119 return false;
120 };
121 parent.kind() == "decorated_definition"
122 && python_decorated_definition_has_fixture(parent, source)
123}
124
125fn python_decorated_definition_has_fixture(node: Node<'_>, source: &str) -> bool {
126 py_node_text(node, source).contains("@pytest.fixture")
127}
128
129fn analyze_python_test_case(
130 file: &ProjectFile,
131 test_case: &PythonTestCase,
132 weights: &TestAssertionWeights,
133 out: &mut Vec<TestAssertionSmell>,
134) {
135 let mut assertions = collect_python_assertions(&test_case.body, weights);
136 for assertion in &mut assertions {
137 assertion.start_byte = test_case.start_byte.saturating_add(assertion.start_byte);
138 }
139 let symbol = format!("{}::{}", file, test_case.name);
140 append_test_assertion_findings(
141 file,
142 symbol,
143 compact_assertion_excerpt(&test_case.body, TEST_ASSERTION_EXCERPT_MAX_LEN),
144 test_case.start_byte,
145 &assertions,
146 weights,
147 out,
148 );
149}
150
151fn collect_python_assertions(
152 body: &str,
153 weights: &TestAssertionWeights,
154) -> Vec<TestAssertionSignal> {
155 let mut assertions = Vec::new();
156
157 for captures in PY_ASSERT_EQUALITY_RE.captures_iter(body) {
158 let whole = captures.get(0).expect("whole match");
159 let left = normalize_python_expr(captures.name("left").map(|m| m.as_str()).unwrap_or(""));
160 let right = normalize_python_expr(captures.name("right").map(|m| m.as_str()).unwrap_or(""));
161 if left.is_empty() || right.is_empty() {
162 continue;
163 }
164 if left == right {
165 let classification = classify_common_test_assertion(
166 if is_python_literal(&left) {
167 CommonTestAssertionKind::ConstantEquality
168 } else {
169 CommonTestAssertionKind::SelfComparison
170 },
171 weights,
172 );
173 assertions.push(TestAssertionSignal {
174 kind: classification.kind.to_string(),
175 score: classification.score,
176 shallow: classification.shallow,
177 meaningful: false,
178 reasons: vec![classification.kind.to_string()],
179 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
180 start_byte: whole.start(),
181 });
182 } else if let Some(literal) = oversized_python_literal(&left, &right, weights) {
183 assertions.push(TestAssertionSignal {
184 kind: "overspecified-literal".to_string(),
185 score: weights.overspecified_literal_weight,
186 shallow: false,
187 meaningful: false,
188 reasons: vec![format!("overspecified-literal:{literal}")],
189 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
190 start_byte: whole.start(),
191 });
192 } else {
193 let classification =
194 classify_common_test_assertion(CommonTestAssertionKind::Meaningful, weights);
195 assertions.push(TestAssertionSignal {
196 kind: classification.kind.to_string(),
197 score: classification.score,
198 shallow: classification.shallow,
199 meaningful: classification.score == 0,
200 reasons: vec![classification.kind.to_string()],
201 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
202 start_byte: whole.start(),
203 });
204 }
205 }
206
207 for captures in PY_ASSERT_TRUTH_RE.captures_iter(body) {
208 let whole = captures.get(0).expect("whole match");
209 let matcher = captures.name("matcher").map(|m| m.as_str()).unwrap_or("");
210 let arg = normalize_python_expr(captures.name("arg").map(|m| m.as_str()).unwrap_or(""));
211 let kind = match matcher {
212 "True" if arg == "True" => CommonTestAssertionKind::ConstantTruth,
213 "False" if arg == "False" => CommonTestAssertionKind::ConstantTruth,
214 "IsNone" | "IsNotNone" => CommonTestAssertionKind::NullnessOnly,
215 _ => CommonTestAssertionKind::Meaningful,
216 };
217 let classification = classify_common_test_assertion(kind, weights);
218 assertions.push(TestAssertionSignal {
219 kind: classification.kind.to_string(),
220 score: classification.score,
221 shallow: classification.shallow,
222 meaningful: classification.score == 0,
223 reasons: vec![classification.kind.to_string()],
224 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
225 start_byte: whole.start(),
226 });
227 }
228
229 for captures in PY_BARE_ASSERT_RE.captures_iter(body) {
230 let whole = captures.get(0).expect("whole match");
231 let expr = normalize_python_expr(captures.name("expr").map(|m| m.as_str()).unwrap_or(""));
232 let trimmed = expr.trim();
233 let maybe_signal = if trimmed == "True" || trimmed == "False" {
234 Some(("constant-truth", weights.constant_truth_weight, true))
235 } else if trimmed.contains(" is not None") || trimmed.contains(" is None") {
236 Some(("nullness-only", weights.nullness_only_weight, true))
237 } else if let Some((left, right)) = trimmed.split_once("==") {
238 let left = normalize_python_expr(left);
239 let right = normalize_python_expr(right);
240 if left == right {
241 if is_python_literal(&left) {
242 Some(("constant-equality", weights.constant_equality_weight, false))
243 } else {
244 Some((
245 "self-comparison",
246 weights.tautological_assertion_weight,
247 false,
248 ))
249 }
250 } else if oversized_python_literal(&left, &right, weights).is_some() {
251 Some((
252 "overspecified-literal",
253 weights.overspecified_literal_weight,
254 false,
255 ))
256 } else {
257 None
258 }
259 } else {
260 None
261 };
262
263 if let Some((kind, score, shallow)) = maybe_signal {
264 assertions.push(TestAssertionSignal {
265 kind: kind.to_string(),
266 score,
267 shallow,
268 meaningful: false,
269 reasons: vec![kind.to_string()],
270 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
271 start_byte: whole.start(),
272 });
273 } else {
274 assertions.push(TestAssertionSignal {
275 kind: "meaningful-assertion".to_string(),
276 score: 0,
277 shallow: false,
278 meaningful: true,
279 reasons: vec!["meaningful-assertion".to_string()],
280 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
281 start_byte: whole.start(),
282 });
283 }
284 }
285
286 for regex in [&*PY_RAISES_RE, &*PY_VERIFY_RE] {
287 for captures in regex.captures_iter(body) {
288 let whole = captures.get(0).expect("whole match");
289 assertions.push(TestAssertionSignal {
290 kind: "meaningful-assertion".to_string(),
291 score: 0,
292 shallow: false,
293 meaningful: true,
294 reasons: vec!["meaningful-assertion".to_string()],
295 excerpt: compact_assertion_excerpt(whole.as_str(), TEST_ASSERTION_EXCERPT_MAX_LEN),
296 start_byte: whole.start(),
297 });
298 }
299 }
300
301 assertions
302}
303
304fn normalize_python_expr(expr: &str) -> String {
305 normalize_assertion_expression(expr.trim().trim_end_matches(',').trim_end_matches(':'))
306}
307
308fn is_python_literal(expr: &str) -> bool {
309 let trimmed = expr.trim();
310 is_common_assertion_literal(trimmed)
311 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
312 || matches!(trimmed, "True" | "False" | "None")
313}
314
315fn oversized_python_literal(
316 left: &str,
317 right: &str,
318 weights: &TestAssertionWeights,
319) -> Option<String> {
320 oversized_quoted_literal(left, right, weights, &['"', '\''])
321}
322
323pub fn python_source_contains_tests(source: &str) -> bool {
324 static TEST_DEF_RE: std::sync::LazyLock<Regex> =
325 std::sync::LazyLock::new(|| Regex::new(r"(?m)^\s*def\s+test_[A-Za-z0-9_]*\s*\(").unwrap());
326 source.contains("@pytest.mark.") || TEST_DEF_RE.is_match(source)
327}