cargo-grip4rust 0.2.0

A cargo subcommand for measuring Rust testability
Documentation
// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
// Licensed under the MIT License
// SPDX-License-Identifier: MIT

use std::path::Path;

use quote::ToTokens;
use syn::visit::Visit;
use syn::{Attribute, Item, ItemFn, Visibility};

use crate::function_info::FunctionInfo;
use crate::item_counts::ItemCounts;
use crate::unsafe_finder::UnsafeFinder;

const KNOWN_FOREIGN_TRAITS: &[&str] = &[
    "Display", "Debug", "Clone", "Default", "PartialEq", "Eq",
    "PartialOrd", "Ord", "Hash", "Into", "From", "TryFrom",
    "Drop", "Deref", "DerefMut", "Index", "IndexMut",
    "Add", "Sub", "Mul", "Div", "Rem", "Neg", "Not",
    "Fn", "FnMut", "FnOnce", "Send", "Sync", "Sized",
    "ToString", "AsRef", "AsMut", "Borrow", "BorrowMut",
    "Error", "Read", "Write", "Seek", "BufRead",
    "Iterator", "IntoIterator", "Future", "IntoFuture",
    "Serialize", "Deserialize",
];

#[derive(Debug)]
pub struct Collector {
    counts: ItemCounts,
    functions: Vec<FunctionInfo>,
    current_file: String,
}

impl Collector {
    fn new(file: String) -> Self {
        Self {
            counts: ItemCounts::default(),
            functions: Vec::new(),
            current_file: file,
        }
    }

    pub fn collect(source: &str, path: &Path) -> (ItemCounts, Vec<FunctionInfo>) {
        let file = path.to_string_lossy().replace('\\', "/");
        let syntax = match syn::parse_file(source) {
            Ok(s) => s,
            Err(_) => return (ItemCounts::default(), Vec::new()),
        };

        let mut collector = Self::new(file);
        for item in &syntax.items {
            collector.visit_item(item);
        }
        (collector.counts, collector.functions)
    }
}

impl<'ast> Visit<'ast> for Collector {
    fn visit_item(&mut self, item: &'ast Item) {
        match item {
            Item::Fn(item_fn) if !self.has_test_attr(&item_fn.attrs) => self.visit_fn(item_fn),
            Item::Struct(item_struct) => self.visit_struct(item_struct),
            Item::Trait(item_trait) => self.visit_trait(item_trait),
            Item::Enum(item_enum) => self.visit_enum(item_enum),
            Item::Mod(item_mod) if !self.has_test_attr(&item_mod.attrs) => self.visit_mod(item_mod),
            Item::Impl(item_impl) => self.visit_impl(item_impl),
            _ => {}
        }
    }
}

impl Collector {
    fn visit_fn(&mut self, item_fn: &ItemFn) {
        let name = item_fn.sig.ident.to_string();
        let is_pub = matches!(
            self.classify_visibility(&item_fn.vis),
            VisibilityLevel::Pub | VisibilityLevel::PubCrate
        );
        let is_pure = self.is_probably_pure(item_fn);

        self.counts.total_functions += 1;
        self.counts.total_items += 1;
        match self.classify_visibility(&item_fn.vis) {
            VisibilityLevel::Pub => {
                self.counts.public_functions += 1;
                self.counts.public_items += 1;
            }
            VisibilityLevel::PubCrate => {
                self.counts.pubcrate_functions += 1;
                self.counts.public_items += 1;
            }
            _ => {}
        }
        if is_pure {
            self.counts.pure_functions += 1;
        }

        self.functions.push(FunctionInfo {
            name,
            file: self.current_file.clone(),
            is_pure,
            is_public: is_pub,
        });
    }

    fn visit_struct(&mut self, item_struct: &syn::ItemStruct) {
        self.counts.total_items += 1;
        if matches!(
            self.classify_visibility(&item_struct.vis),
            VisibilityLevel::Pub | VisibilityLevel::PubCrate
        ) {
            self.counts.public_structs += 1;
            self.counts.public_items += 1;
        }
    }

    fn visit_trait(&mut self, item_trait: &syn::ItemTrait) {
        self.counts.total_items += 1;
        if matches!(
            self.classify_visibility(&item_trait.vis),
            VisibilityLevel::Pub | VisibilityLevel::PubCrate
        ) {
            self.counts.public_traits += 1;
            self.counts.public_items += 1;
        }
    }

    fn visit_enum(&mut self, item_enum: &syn::ItemEnum) {
        self.counts.total_items += 1;
        if matches!(
            self.classify_visibility(&item_enum.vis),
            VisibilityLevel::Pub | VisibilityLevel::PubCrate
        ) {
            self.counts.public_enums += 1;
            self.counts.public_items += 1;
        }
    }

    fn visit_mod(&mut self, item_mod: &syn::ItemMod) {
        if let Some((_, items)) = &item_mod.content {
            for inner in items {
                self.visit_item(inner);
            }
        }
    }

    fn visit_impl(&mut self, item_impl: &syn::ItemImpl) {
        if let Some((_, trait_path, _)) = &item_impl.trait_ {
            if self.is_foreign_trait(trait_path) {
                return;
            }
            for item in &item_impl.items {
                if let syn::ImplItem::Fn(method) = item {
                    if self.has_test_attr(&method.attrs) {
                        continue;
                    }
                    self.counts.total_functions += 1;
                    self.counts.local_trait_methods += 1;
                    if self.is_impl_method_impure(method) {
                        self.counts.local_trait_impure += 1;
                    }
                }
            }
        } else {
            for item in &item_impl.items {
                if let syn::ImplItem::Fn(method) = item {
                    self.counts.total_functions += 1;
                    self.counts.inherent_methods += 1;
                    if self.has_test_attr(&method.attrs) {
                        continue;
                    }
                    if self.is_impl_method_impure(method) {
                        self.counts.inherent_impure += 1;
                    }
                }
            }
        }
    }

    fn is_impl_method_impure(&self, method: &syn::ImplItemFn) -> bool {
        if self.has_mut_param(&method.sig) {
            return true;
        }
        if self.is_unit_return(&method.sig) {
            return true;
        }
        if method.sig.unsafety.is_some() {
            return true;
        }
        self.has_unsafe_block(&method.block) || self.has_io_call(&method.block)
    }

    fn is_foreign_trait(&self, path: &syn::Path) -> bool {
        if let Some(last) = path.segments.last() {
            let name = last.ident.to_string();
            if KNOWN_FOREIGN_TRAITS.contains(&name.as_str()) {
                return true;
            }
        }
        if path.segments.len() > 1 {
            if let Some(first) = path.segments.first() {
                let name = first.ident.to_string();
                return name == "std" || name == "core" || name == "alloc";
            }
        }
        false
    }



    fn classify_visibility(&self, vis: &Visibility) -> VisibilityLevel {
        match vis {
            Visibility::Public(_) => VisibilityLevel::Pub,
            Visibility::Restricted(_) => VisibilityLevel::PubCrate,
            _ => VisibilityLevel::Private,
        }
    }

    fn has_test_attr(&self, attrs: &[Attribute]) -> bool {
        attrs.iter().any(|attr| {
            let tokens = attr.to_token_stream().to_string();
            let path = attr.path().get_ident().map(|i| i.to_string());
            matches!(path.as_deref(), Some("cfg")) && tokens.contains("test")
                || matches!(path.as_deref(), Some("test"))
                || matches!(path.as_deref(), Some("cfg_attr")) && tokens.contains("test")
        })
    }

    fn is_probably_pure(&self, item_fn: &ItemFn) -> bool {
        if self.has_mut_param(&item_fn.sig) {
            return false;
        }
        if self.is_unit_return(&item_fn.sig) {
            return false;
        }
        if item_fn.sig.unsafety.is_some() {
            return false;
        }
        !self.has_unsafe_block(&item_fn.block)
    }

    fn has_mut_param(&self, sig: &syn::Signature) -> bool {
        sig.inputs.iter().any(|arg| match arg {
            syn::FnArg::Receiver(recv) => recv.mutability.is_some(),
            syn::FnArg::Typed(pat_type) => self.has_mut_in_type(&pat_type.ty),
        })
    }

    #[allow(clippy::only_used_in_recursion)]
    fn has_mut_in_type(&self, ty: &syn::Type) -> bool {
        use syn::Type;
        match ty {
            Type::Reference(reference) => reference.mutability.is_some(),
            Type::Paren(inner) => self.has_mut_in_type(&inner.elem),
            _ => false,
        }
    }

    fn is_unit_return(&self, sig: &syn::Signature) -> bool {
        match &sig.output {
            syn::ReturnType::Default => true,
            syn::ReturnType::Type(_, ty) => {
                if let syn::Type::Tuple(tuple) = ty.as_ref() {
                    tuple.elems.is_empty()
                } else {
                    false
                }
            }
        }
    }

    fn has_unsafe_block(&self, block: &syn::Block) -> bool {
        let mut finder = UnsafeFinder::new();
        finder.visit_block(block);
        finder.found
    }

    fn has_io_call(&self, block: &syn::Block) -> bool {
        let mut finder = crate::io_call_finder::IoCallFinder::new();
        finder.visit_block(block);
        finder.found
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VisibilityLevel {
    Private,
    PubCrate,
    Pub,
}