Skip to main content

brokk_bifrost_ruby/
test_detection.rs

1//! Ruby test-file recognition and test-assertion smell detection.
2//!
3//! `analyzer/ruby/tests.rs` in `brokk-bifrost-analysis` keeps the empty
4//! `impl TestDetectionProvider for RubyAnalyzer` and the analyzer-bound
5//! fixture suites; everything here is a pure function of a parsed file.
6
7use 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
69/// Heuristic detection of Ruby test files across the common frameworks:
70/// RSpec, Minitest, and Test::Unit. Recognition is entirely parser-backed so
71/// assertion-like text in comments and strings cannot classify a file as a
72/// test.
73pub 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}