rustqual 1.6.0

Comprehensive Rust code quality analyzer — seven dimensions: IOSP, Complexity, DRY, SRP, Coupling, Test Quality, Architecture
Documentation
use syn::visit::Visit;

use super::{TqWarning, TqWarningKind};

/// Core assertion macros from std. Detection uses prefix matching (`assert*`, `debug_assert*`)
/// so any crate-provided `assert_*` macro (e.g. `assert_relative_eq!`) is automatically recognized.
/// This list is retained as documentation of the well-known set.
#[cfg(doc)]
const _ASSERTION_MACROS: &[&str] = &[
    "assert",
    "assert_eq",
    "assert_ne",
    "debug_assert",
    "debug_assert_eq",
    "debug_assert_ne",
];

/// Detect test functions that have no assertions (TQ-001).
/// Operation: iterates parsed files, visits test functions, checks for assertions.
/// Recognizes all `assert*` macros by prefix, plus any extra macros from config.
pub(crate) fn detect_assertion_free_tests(
    parsed: &[(String, String, syn::File)],
    extra_assertion_macros: &[String],
) -> Vec<TqWarning> {
    let mut warnings = Vec::new();
    for (path, _, syntax) in parsed {
        let mut collector = TestFunctionCollector::default();
        collector.visit_file(syntax);
        for test_fn in &collector.test_fns {
            let mut visitor = TestAssertionVisitor {
                extra_macros: extra_assertion_macros,
                ..Default::default()
            };
            visitor.visit_block(&test_fn.body);
            if !(visitor.has_assertion
                || (test_fn.should_panic && visitor.has_panic)
                || visitor.has_call
                || test_fn.returns_bool)
            {
                warnings.push(TqWarning {
                    file: path.clone(),
                    line: test_fn.line,
                    function_name: test_fn.name.clone(),
                    kind: TqWarningKind::NoAssertion,
                    suppressed: false,
                });
            }
        }
    }
    warnings
}

/// A collected test function with its body for assertion analysis.
struct TestFnInfo {
    name: String,
    line: usize,
    body: syn::Block,
    should_panic: bool,
    /// Return type is `bool` — a quickcheck-style property whose boolean
    /// verdict is its oracle (see `is_bool_return`).
    returns_bool: bool,
}

/// Collects `#[test]` functions from a file.
#[derive(Default)]
struct TestFunctionCollector {
    test_fns: Vec<TestFnInfo>,
    in_cfg_test: bool,
}

impl<'ast> Visit<'ast> for TestFunctionCollector {
    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
        let was_in_cfg_test = self.in_cfg_test;
        if has_cfg_test(&node.attrs) {
            self.in_cfg_test = true;
        }
        syn::visit::visit_item_mod(self, node);
        self.in_cfg_test = was_in_cfg_test;
    }

    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
        if has_test_attr(&node.attrs) {
            let line = node.sig.ident.span().start().line;
            self.test_fns.push(TestFnInfo {
                name: node.sig.ident.to_string(),
                line,
                body: (*node.block).clone(),
                should_panic: has_should_panic_attr(&node.attrs),
                returns_bool: is_bool_return(&node.sig.output),
            });
        }
        syn::visit::visit_item_fn(self, node);
    }
}

/// Visits a function body looking for assertion macro calls and function calls.
#[derive(Default)]
struct TestAssertionVisitor<'cfg> {
    has_assertion: bool,
    has_panic: bool,
    /// Whether the test body calls any function (implicit no-panic assertion).
    has_call: bool,
    /// Extra macro names (beyond `assert*` prefix) to treat as assertions.
    extra_macros: &'cfg [String],
}

/// Check if a macro name is an assertion: `assert*`, `debug_assert*` or
/// proptest's `prop_assert*` (`prop_assert!` / `prop_assert_eq!` /
/// `prop_assert_ne!`) prefix, or in the extra list.
/// Operation: string prefix + linear search.
fn is_assertion_macro(name: &str, extra_macros: &[String]) -> bool {
    name.starts_with("assert")
        || name.starts_with("debug_assert")
        || name.starts_with("prop_assert")
        || extra_macros.iter().any(|m| m == name)
}

impl<'ast> Visit<'ast> for TestAssertionVisitor<'_> {
    fn visit_expr_macro(&mut self, node: &'ast syn::ExprMacro) {
        let macro_name = macro_ident_name(&node.mac);
        if is_assertion_macro(&macro_name, self.extra_macros) {
            self.has_assertion = true;
        }
        if macro_name == "panic" {
            self.has_panic = true;
        }
        syn::visit::visit_expr_macro(self, node);
    }

    fn visit_macro(&mut self, node: &'ast syn::Macro) {
        let macro_name = macro_ident_name(node);
        if is_assertion_macro(&macro_name, self.extra_macros) {
            self.has_assertion = true;
        }
        if macro_name == "panic" {
            self.has_panic = true;
        }
        syn::visit::visit_macro(self, node);
    }

    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
        self.has_call = true;
        syn::visit::visit_expr_call(self, node);
    }

    fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
        self.has_call = true;
        syn::visit::visit_expr_method_call(self, node);
    }
}

/// Extract the final segment name from a macro path.
/// Operation: path segment extraction.
fn macro_ident_name(mac: &syn::Macro) -> String {
    mac.path
        .segments
        .last()
        .map(|s| s.ident.to_string())
        .unwrap_or_default()
}

use crate::adapters::shared::cfg_test::{has_cfg_test, has_test_attr};

/// Check if attributes contain `#[should_panic]`.
/// Operation: attribute matching.
fn has_should_panic_attr(attrs: &[syn::Attribute]) -> bool {
    attrs.iter().any(|a| a.path().is_ident("should_panic"))
}

/// True if the function returns `bool` — a quickcheck-style property whose
/// boolean verdict IS its assertion. The macro-expansion pre-pass surfaces
/// `quickcheck! { fn p(x: T) -> bool { … } }` as a synthetic `#[test] fn p()
/// -> bool { … }` with its params dropped, so a body like `{ x < 100 }` carries
/// no assertion macro or call — the returned bool is the oracle quickcheck
/// checks across every generated input. A plain `#[test]` cannot return `bool`
/// (it would not compile), so this signal is quickcheck-specific and safe.
/// Operation: return-type path inspection.
fn is_bool_return(output: &syn::ReturnType) -> bool {
    let syn::ReturnType::Type(_, ty) = output else {
        return false;
    };
    matches!(&**ty, syn::Type::Path(tp)
        if tp.path.segments.last().is_some_and(|s| s.ident == "bool"))
}