1use crate::declarations::{extract_name_segments, parse_ruby_tree, ruby_node_text};
8use crate::syntax::{
9 ruby_call_arguments, ruby_first_call_argument, single_static_string_content_node,
10};
11use brokk_bifrost_core::analyzer::ProjectFile;
12use brokk_bifrost_core::analyzer::model::{
13 TestAssertionAnalysis, TestAssertionSmell, TestAssertionWeights,
14};
15use brokk_bifrost_core::analyzer::test_assertions::{
16 TestAssertionSignal, append_test_assertion_findings, compact_assertion_excerpt,
17};
18use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_named_tree_preorder};
19use tree_sitter::Node;
20
21const TEST_ASSERTION_EXCERPT_MAX_LEN: usize = 180;
22const RSPEC_DESCRIPTION_MAX_LEN: usize = 180;
23const MIN_RUBY_ASSERTION_NODE_BUDGET: usize = 10_000;
24const MAX_RUBY_ASSERTION_NODE_BUDGET: usize = 1_000_000;
25const RUBY_ASSERTION_NODES_PER_CANDIDATE: usize = 100;
26
27const MINITEST_ASSERTION_METHODS: &[&str] = &[
28 "assert",
29 "refute",
30 "assert_empty",
31 "refute_empty",
32 "assert_equal",
33 "refute_equal",
34 "assert_in_delta",
35 "refute_in_delta",
36 "assert_in_epsilon",
37 "refute_in_epsilon",
38 "assert_includes",
39 "refute_includes",
40 "assert_instance_of",
41 "refute_instance_of",
42 "assert_kind_of",
43 "refute_kind_of",
44 "assert_match",
45 "refute_match",
46 "assert_nil",
47 "refute_nil",
48 "assert_operator",
49 "refute_operator",
50 "assert_output",
51 "assert_path_exists",
52 "refute_path_exists",
53 "assert_pattern",
54 "refute_pattern",
55 "assert_predicate",
56 "refute_predicate",
57 "assert_raises",
58 "assert_respond_to",
59 "refute_respond_to",
60 "assert_same",
61 "refute_same",
62 "assert_send",
63 "assert_silent",
64 "assert_throws",
65 "flunk",
66 "pass",
67];
68
69pub fn ruby_contains_tests(root: Node<'_>, source: &str) -> bool {
74 let mut found = false;
75 walk_named_tree_preorder(root, true, |node| {
76 found = match node.kind() {
77 "method" => {
78 ruby_method_name(node, source).is_some_and(|name| name.starts_with("test_"))
79 }
80 "class" => ruby_test_superclass(node, source),
81 "call" => ruby_test_marker_call(node, source),
82 _ => false,
83 };
84 if found {
85 WalkControl::Break
86 } else {
87 WalkControl::Continue
88 }
89 });
90 found
91}
92
93#[derive(Clone)]
94struct RubyTestCase<'tree> {
95 name: String,
96 body: Node<'tree>,
97 start_byte: usize,
98}
99
100pub fn detect_ruby_test_assertion_smells(
101 file: &ProjectFile,
102 source: &str,
103 weights: &TestAssertionWeights,
104) -> Vec<TestAssertionSmell> {
105 detect_ruby_test_assertion_smells_limited(file, source, weights, usize::MAX).findings
106}
107
108pub fn detect_ruby_test_assertion_smells_limited(
109 file: &ProjectFile,
110 source: &str,
111 weights: &TestAssertionWeights,
112 max_candidates: usize,
113) -> TestAssertionAnalysis {
114 let Some(tree) = parse_ruby_tree(source) else {
115 return TestAssertionAnalysis {
116 findings: Vec::new(),
117 inspected_candidates: Some(0),
118 truncated: false,
119 };
120 };
121 let mut findings = Vec::new();
122 let mut inspected_candidates = 0usize;
123 let mut remaining_nodes = ruby_assertion_node_budget(max_candidates);
124 let mut truncated = false;
125 walk_named_tree_preorder(tree.root_node(), true, |node| {
126 if remaining_nodes == 0 {
127 truncated = true;
128 return WalkControl::Break;
129 }
130 remaining_nodes -= 1;
131 if let Some(test_case) = ruby_test_case(node, source) {
132 if inspected_candidates >= max_candidates {
133 truncated = true;
134 return WalkControl::Break;
135 }
136 let remaining = max_candidates - inspected_candidates;
137 let assertions = collect_ruby_assertions_limited(
138 test_case.body,
139 source,
140 weights,
141 remaining,
142 &mut remaining_nodes,
143 );
144 if assertions.truncated {
145 truncated = true;
146 return WalkControl::Break;
147 }
148 inspected_candidates += assertions.signals.len().max(1);
149 analyze_ruby_test_case(
150 file,
151 source,
152 test_case,
153 weights,
154 assertions.signals,
155 &mut findings,
156 );
157 if findings.len() > max_candidates {
158 findings.truncate(max_candidates);
159 truncated = true;
160 }
161 if truncated {
162 WalkControl::Break
163 } else {
164 WalkControl::SkipChildren
165 }
166 } else {
167 WalkControl::Continue
168 }
169 });
170 TestAssertionAnalysis {
171 findings,
172 inspected_candidates: Some(inspected_candidates),
173 truncated,
174 }
175}
176
177fn ruby_test_case<'tree>(node: Node<'tree>, source: &'tree str) -> Option<RubyTestCase<'tree>> {
178 if node.kind() == "method" {
179 let name = ruby_method_name(node, source)?;
180 if !name.starts_with("test_") {
181 return None;
182 }
183 return Some(RubyTestCase {
184 name: bounded_test_case_name(name),
185 body: node.child_by_field_name("body").unwrap_or(node),
186 start_byte: node.start_byte(),
187 });
188 }
189 if node.kind() != "call" || call_receiver(node).is_some() {
190 return None;
191 }
192 let method = call_method_name(node, source)?;
193 if !matches!(method, "it" | "specify") {
194 return None;
195 }
196 let block = node.child_by_field_name("block")?;
197 let description = ruby_first_call_argument(node)
198 .and_then(static_string_content)
199 .map(|content| ruby_node_text(content, source))
200 .filter(|description| !description.is_empty())
201 .unwrap_or(method);
202 Some(RubyTestCase {
203 name: bounded_test_case_name(description),
204 body: block.child_by_field_name("body").unwrap_or(block),
205 start_byte: node.start_byte(),
206 })
207}
208
209fn analyze_ruby_test_case(
210 file: &ProjectFile,
211 source: &str,
212 test_case: RubyTestCase<'_>,
213 weights: &TestAssertionWeights,
214 assertions: Vec<TestAssertionSignal>,
215 out: &mut Vec<TestAssertionSmell>,
216) {
217 let symbol = format!("{}::{}", file, test_case.name);
218 append_test_assertion_findings(
219 file,
220 symbol,
221 compact_ruby_excerpt(ruby_node_text(test_case.body, source)),
222 test_case.start_byte,
223 &assertions,
224 weights,
225 out,
226 );
227}
228
229struct RubyAssertionCollection {
230 signals: Vec<TestAssertionSignal>,
231 truncated: bool,
232}
233
234fn collect_ruby_assertions_limited(
235 body: Node<'_>,
236 source: &str,
237 weights: &TestAssertionWeights,
238 max_assertions: usize,
239 remaining_nodes: &mut usize,
240) -> RubyAssertionCollection {
241 let mut assertions = Vec::new();
242 let mut truncated = false;
243 walk_named_tree_preorder(body, true, |node| {
244 if *remaining_nodes == 0 {
245 truncated = true;
246 return WalkControl::Break;
247 }
248 *remaining_nodes -= 1;
249 if node != body && ruby_test_case(node, source).is_some() {
250 return WalkControl::SkipChildren;
251 }
252 if matches!(node.kind(), "method" | "singleton_method")
253 || ruby_deferred_callable(node, source)
254 {
255 return WalkControl::SkipChildren;
256 }
257 if node.kind() != "call" {
258 return WalkControl::Continue;
259 }
260 if let Some(signal) = ruby_assertion_signal(node, source, weights) {
261 if assertions.len() >= max_assertions {
262 truncated = true;
263 return WalkControl::Break;
264 }
265 assertions.push(signal);
266 WalkControl::SkipChildren
267 } else {
268 WalkControl::Continue
269 }
270 });
271 RubyAssertionCollection {
272 signals: assertions,
273 truncated,
274 }
275}
276
277fn ruby_assertion_node_budget(max_candidates: usize) -> usize {
278 if max_candidates == usize::MAX {
279 return usize::MAX;
280 }
281 max_candidates
282 .saturating_mul(RUBY_ASSERTION_NODES_PER_CANDIDATE)
283 .clamp(
284 MIN_RUBY_ASSERTION_NODE_BUDGET,
285 MAX_RUBY_ASSERTION_NODE_BUDGET,
286 )
287}
288
289fn ruby_assertion_signal(
290 call: Node<'_>,
291 source: &str,
292 weights: &TestAssertionWeights,
293) -> Option<TestAssertionSignal> {
294 if let Some(signal) = rspec_assertion_signal(call, source, weights) {
295 return Some(signal);
296 }
297 let method = call_method_name(call, source)?;
298 let receiver = call_receiver(call);
299 if receiver.is_none_or(|receiver| receiver.kind() == "self")
300 && MINITEST_ASSERTION_METHODS.contains(&method)
301 {
302 return Some(minitest_assertion_signal(call, method, source, weights));
303 }
304 if matches!(
305 method,
306 "must_equal"
307 | "wont_equal"
308 | "must_same"
309 | "wont_same"
310 | "must_be_nil"
311 | "wont_be_nil"
312 | "must_raise"
313 | "must_throw"
314 ) && receiver.is_some()
315 {
316 return Some(minitest_expectation_signal(call, method, source, weights));
317 }
318 None
319}
320
321fn minitest_assertion_signal(
322 call: Node<'_>,
323 method: &str,
324 source: &str,
325 weights: &TestAssertionWeights,
326) -> TestAssertionSignal {
327 let arguments = ruby_call_arguments(call);
328 let classification = match method {
329 "assert_equal" | "refute_equal" | "assert_same" | "refute_same" => arguments
330 .first()
331 .zip(arguments.get(1))
332 .map(|(&left, &right)| classify_ruby_equality(left, right, source, weights)),
333 "assert_nil" | "refute_nil" => {
334 Some(("nullness-only", weights.nullness_only_weight, true, false))
335 }
336 "assert" if arguments.first().is_some_and(|node| node.kind() == "true") => {
337 Some(("constant-truth", weights.constant_truth_weight, true, false))
338 }
339 "refute" if arguments.first().is_some_and(|node| node.kind() == "false") => {
340 Some(("constant-truth", weights.constant_truth_weight, true, false))
341 }
342 _ => None,
343 };
344 assertion_signal(call, source, classification, weights, arguments.into_iter())
345}
346
347fn minitest_expectation_signal(
348 call: Node<'_>,
349 method: &str,
350 source: &str,
351 weights: &TestAssertionWeights,
352) -> TestAssertionSignal {
353 let receiver = call_receiver(call).expect("expectation method has receiver");
354 let arguments = ruby_call_arguments(call);
355 let classification = match method {
356 "must_equal" | "wont_equal" | "must_same" | "wont_same" => arguments
357 .first()
358 .map(|&argument| classify_ruby_equality(receiver, argument, source, weights)),
359 "must_be_nil" | "wont_be_nil" => {
360 Some(("nullness-only", weights.nullness_only_weight, true, false))
361 }
362 _ => None,
363 };
364 assertion_signal(
365 call,
366 source,
367 classification,
368 weights,
369 std::iter::once(receiver).chain(arguments),
370 )
371}
372
373fn rspec_assertion_signal(
374 call: Node<'_>,
375 source: &str,
376 weights: &TestAssertionWeights,
377) -> Option<TestAssertionSignal> {
378 if !matches!(call_method_name(call, source)?, "to" | "not_to" | "to_not") {
379 return None;
380 }
381 let expectation = call_receiver(call)?;
382 let actual = if expectation.kind() == "call"
383 && call_method_name(expectation, source) == Some("expect")
384 && call_receiver(expectation).is_none()
385 {
386 ruby_first_call_argument(expectation)
387 } else if ruby_is_bare_call_named(expectation, "is_expected", source) {
388 None
389 } else {
390 return None;
391 };
392 let matcher = ruby_first_call_argument(call)?;
393 let matcher_method = if matcher.kind() == "call" {
394 call_method_name(matcher, source)
395 } else if matcher.kind() == "identifier" {
396 Some(ruby_node_text(matcher, source))
397 } else {
398 None
399 };
400 let matcher_arguments = if matcher.kind() == "call" {
401 ruby_call_arguments(matcher)
402 } else {
403 Vec::new()
404 };
405 let built_in_matcher = matcher.kind() != "call" || call_receiver(matcher).is_none();
406 let classification = match matcher_method.filter(|_| built_in_matcher) {
407 Some("eq" | "eql" | "equal" | "be") => actual
408 .zip(matcher_arguments.first().copied())
409 .map(|(left, right)| classify_ruby_equality(left, right, source, weights)),
410 Some("be_nil") => Some(("nullness-only", weights.nullness_only_weight, true, false)),
411 Some("be_truthy" | "be_falsey") => actual.and_then(|node| {
412 matches!(node.kind(), "true" | "false").then_some((
413 "constant-truth",
414 weights.constant_truth_weight,
415 true,
416 false,
417 ))
418 }),
419 _ => None,
420 };
421 assertion_signal(
422 call,
423 source,
424 classification,
425 weights,
426 actual.into_iter().chain(matcher_arguments),
427 )
428 .into()
429}
430
431fn classify_ruby_equality(
432 left: Node<'_>,
433 right: Node<'_>,
434 source: &str,
435 weights: &TestAssertionWeights,
436) -> (&'static str, i32, bool, bool) {
437 if ruby_node_text(left, source).trim() == ruby_node_text(right, source).trim() {
438 if ruby_scalar_literal(left) {
439 (
440 "constant-equality",
441 weights.constant_equality_weight,
442 false,
443 false,
444 )
445 } else {
446 (
447 "self-comparison",
448 weights.tautological_assertion_weight,
449 false,
450 false,
451 )
452 }
453 } else if ruby_scalar_literal(left) && ruby_scalar_literal(right) {
454 (
455 "constant-equality",
456 weights.constant_equality_weight,
457 false,
458 false,
459 )
460 } else {
461 ("meaningful-assertion", 0, false, true)
462 }
463}
464
465fn assertion_signal<'tree>(
466 call: Node<'tree>,
467 source: &str,
468 classification: Option<(&'static str, i32, bool, bool)>,
469 weights: &TestAssertionWeights,
470 mut literal_candidates: impl Iterator<Item = Node<'tree>>,
471) -> TestAssertionSignal {
472 let oversized_literal = literal_candidates.any(|node| oversized_ruby_string(node, weights));
473 let (mut kind, mut score, shallow, mut meaningful) =
474 classification.unwrap_or(("meaningful-assertion", 0, false, true));
475 let mut reasons: Vec<String> = (score > 0).then(|| kind.to_string()).into_iter().collect();
476 if oversized_literal {
477 score += weights.overspecified_literal_weight;
478 reasons.push("overspecified-literal".to_string());
479 kind = "overspecified-literal";
480 meaningful = false;
481 }
482 TestAssertionSignal {
483 kind: kind.to_string(),
484 score,
485 shallow,
486 meaningful,
487 reasons,
488 excerpt: compact_ruby_excerpt(ruby_node_text(call, source)),
489 start_byte: call.start_byte(),
490 }
491}
492
493fn ruby_test_marker_call(node: Node<'_>, source: &str) -> bool {
494 let Some(method) = call_method_name(node, source) else {
495 return false;
496 };
497 let receiver = call_receiver(node);
498 if receiver.is_none() && matches!(method, "describe" | "context" | "it" | "specify") {
499 return true;
500 }
501 if method == "describe"
502 && receiver.is_some_and(|receiver| ruby_node_text(receiver, source) == "RSpec")
503 {
504 return true;
505 }
506 if !matches!(method, "require" | "require_relative") || receiver.is_some() {
507 return false;
508 }
509 ruby_first_call_argument(node)
510 .and_then(static_string_content)
511 .map(|content| ruby_node_text(content, source))
512 .is_some_and(|required| {
513 matches!(required, "spec_helper" | "test_helper" | "test/unit")
514 || required == "rspec"
515 || required.starts_with("rspec/")
516 || required == "minitest"
517 || required.starts_with("minitest/")
518 })
519}
520
521fn ruby_test_superclass(node: Node<'_>, source: &str) -> bool {
522 let Some(superclass) = node.child_by_field_name("superclass") else {
523 return false;
524 };
525 let Some(name) = superclass.named_child(0) else {
526 return false;
527 };
528 matches!(
529 extract_name_segments(name, source).as_slice(),
530 [first, second] if (first == "Minitest" || first == "MiniTest") && second == "Test"
531 ) || matches!(
532 extract_name_segments(name, source).as_slice(),
533 [first, second, third] if first == "Test" && second == "Unit" && third == "TestCase"
534 )
535}
536
537fn ruby_method_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
538 node.child_by_field_name("name")
539 .map(|name| ruby_node_text(name, source))
540}
541
542fn call_method_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
543 node.child_by_field_name("method")
544 .map(|method| ruby_node_text(method, source))
545}
546
547fn call_receiver(node: Node<'_>) -> Option<Node<'_>> {
548 node.child_by_field_name("receiver")
549}
550
551fn static_string_content(node: Node<'_>) -> Option<Node<'_>> {
552 (node.kind() == "string")
553 .then(|| single_static_string_content_node(node))
554 .flatten()
555}
556
557fn ruby_scalar_literal(node: Node<'_>) -> bool {
558 matches!(
559 node.kind(),
560 "integer" | "float" | "complex" | "rational" | "true" | "false" | "nil" | "simple_symbol"
561 ) || static_string_content(node).is_some()
562}
563
564fn oversized_ruby_string(node: Node<'_>, weights: &TestAssertionWeights) -> bool {
565 static_string_content(node).is_some_and(|content| {
566 content.end_byte().saturating_sub(content.start_byte())
567 > weights.large_literal_length_threshold.max(0) as usize
568 })
569}
570
571fn compact_ruby_excerpt(text: &str) -> String {
572 compact_assertion_excerpt(text, TEST_ASSERTION_EXCERPT_MAX_LEN)
573}
574
575fn bounded_test_case_name(description: &str) -> String {
576 if description.chars().count() <= RSPEC_DESCRIPTION_MAX_LEN {
577 return description.to_string();
578 }
579 let end = description
580 .char_indices()
581 .nth(RSPEC_DESCRIPTION_MAX_LEN)
582 .map(|(index, _)| index)
583 .unwrap_or(description.len());
584 format!("{}...", &description[..end])
585}
586
587fn ruby_deferred_callable(node: Node<'_>, source: &str) -> bool {
588 if node.kind() == "lambda" {
589 return true;
590 }
591 if node.kind() != "call" || node.child_by_field_name("block").is_none() {
592 return false;
593 }
594 let receiver = call_receiver(node);
595 let Some(method) = call_method_name(node, source) else {
596 return false;
597 };
598 (receiver.is_none_or(|receiver| receiver.kind() == "self")
599 && matches!(
600 method,
601 "proc" | "lambda" | "define_method" | "define_singleton_method"
602 ))
603 || (method == "new"
604 && receiver.is_some_and(|receiver| {
605 matches!(extract_name_segments(receiver, source).as_slice(), [name] if name == "Proc")
606 }))
607}
608
609fn ruby_is_bare_call_named(node: Node<'_>, expected: &str, source: &str) -> bool {
610 (node.kind() == "identifier" && ruby_node_text(node, source) == expected)
611 || (node.kind() == "call"
612 && call_receiver(node).is_none()
613 && call_method_name(node, source) == Some(expected))
614}