cargo-grip4rust 0.6.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 syn::visit::Visit;

const STD_CONSTRUCTORS: &[&str] = &[
    "Box", "Arc", "Rc", "String", "Vec", "HashMap", "HashSet", "Option", "Result", "Ok", "Err",
    "Some", "None", "Ordering", "Duration", "Path", "PathBuf", "CString", "CStr", "OsString",
    "OsStr", "Default", "Clone",
];

const STD_MODULE_CALLS: &[&str] = &[
    "fs::read",
    "fs::write",
    "fs::read_to_string",
    "fs::read_dir",
    "fs::create_dir",
    "fs::create_dir_all",
    "fs::remove_file",
    "fs::remove_dir",
    "fs::remove_dir_all",
    "fs::copy",
    "fs::rename",
    "fs::metadata",
    "env::var",
    "env::args",
    "env::temp_dir",
    "env::current_dir",
    "env::current_exe",
    "env::set_var",
    "process::exit",
    "process::abort",
    "process::id",
    "thread::sleep",
    "thread::spawn",
    "net::TcpStream",
    "net::TcpListener",
    "net::UdpSocket",
];

fn dep_weight(label: &str) -> f64 {
    if label.starts_with("println")
        || label.starts_with("eprintln")
        || label.starts_with("print!")
        || label.starts_with("eprint!")
    {
        0.2
    } else if label.starts_with("Instant")
        || label.starts_with("SystemTime")
        || label.starts_with("Utc")
        || label.starts_with("Local")
        || label.contains("elapsed")
    {
        0.3
    } else if label.starts_with("env::") || label.starts_with("process::") {
        0.4
    } else if label.starts_with("unsafe") {
        0.5
    } else {
        0.6
    }
}

fn path_label(path: &syn::Path) -> String {
    path.segments
        .iter()
        .map(|s| s.ident.to_string())
        .collect::<Vec<_>>()
        .join("::")
}

pub(crate) struct HiddenDepFinder {
    pub(crate) count: usize,
    pub(crate) weight: f64,
    pub(crate) labels: Vec<String>,
    concrete_fields: Vec<String>,
}

impl HiddenDepFinder {
    pub(crate) fn new() -> Self {
        Self {
            count: 0,
            weight: 0.0,
            labels: Vec::new(),
            concrete_fields: Vec::new(),
        }
    }

    pub(crate) fn set_concrete_fields(&mut self, fields: Vec<String>) {
        self.concrete_fields = fields;
    }

    fn add_dep(&mut self, label: &str) {
        self.count += 1;
        let w = dep_weight(label);
        self.weight += w;
        self.labels.push(label.to_string());
    }

    fn check_path(&mut self, path: &syn::Path) {
        let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect();
        if segments.is_empty() {
            return;
        }
        if segments[0] == "Self" || segments[0] == "self" {
            return;
        }

        let tail_start = segments.len().saturating_sub(2);
        let tail = segments[tail_start..].join("::");
        if STD_MODULE_CALLS.contains(&tail.as_str())
            && (segments.len() <= 2 || segments[0] == "std" || segments[0] == "core")
        {
            self.add_dep(&tail);
            return;
        }

        let first = &segments[0];
        if first.starts_with(|c: char| c.is_ascii_uppercase())
            && !STD_CONSTRUCTORS.contains(&first.as_str())
        {
            let label = segments.join("::");
            self.add_dep(&label);
        }
    }
}

fn is_print_macro(path: &syn::Path) -> bool {
    if let Some(name) = path.get_ident() {
        let n = name.to_string();
        return n == "println" || n == "eprintln" || n == "print" || n == "eprint";
    }
    false
}

impl<'ast> Visit<'ast> for HiddenDepFinder {
    fn visit_stmt(&mut self, stmt: &'ast syn::Stmt) {
        if let syn::Stmt::Macro(stmt_macro) = stmt {
            if is_print_macro(&stmt_macro.mac.path) {
                self.add_dep(&path_label(&stmt_macro.mac.path));
                return;
            }
        }
        syn::visit::visit_stmt(self, stmt);
    }

    fn visit_expr(&mut self, expr: &'ast syn::Expr) {
        if self.handle_expr(expr) {
            syn::visit::visit_expr(self, expr);
        }
    }

    fn visit_expr_unsafe(&mut self, _expr: &'ast syn::ExprUnsafe) {
        self.add_dep("unsafe { ... }");
    }
}

impl HiddenDepFinder {
    /// Returns whether the visitor should still recurse into `expr`'s children.
    fn handle_expr(&mut self, expr: &syn::Expr) -> bool {
        match expr {
            syn::Expr::Call(expr_call) => {
                if let syn::Expr::Path(expr_path) = &*expr_call.func {
                    self.check_path(&expr_path.path);
                }
                true
            }
            syn::Expr::MethodCall(expr_method) => self.handle_method_call_expr(expr_method),
            syn::Expr::Macro(expr_macro) => self.handle_macro_expr(expr_macro),
            _ => true,
        }
    }

    fn handle_method_call_expr(&mut self, expr_method: &syn::ExprMethodCall) -> bool {
        if Self::is_bare_self(&expr_method.receiver) {
            return false;
        }
        if let Some(name) = Self::concrete_field_name(&expr_method.receiver) {
            if self.concrete_fields.contains(&name) {
                let label = format!("self.{name}.{}", expr_method.method);
                self.add_dep(&label);
            }
        }
        true
    }

    fn handle_macro_expr(&mut self, expr_macro: &syn::ExprMacro) -> bool {
        if is_print_macro(&expr_macro.mac.path) {
            self.add_dep(&path_label(&expr_macro.mac.path));
            return false;
        }
        true
    }

    fn is_bare_self(receiver: &syn::Expr) -> bool {
        matches!(
            receiver,
            syn::Expr::Path(expr_path)
                if expr_path.path.segments.len() == 1 && expr_path.path.segments[0].ident == "self"
        )
    }

    fn concrete_field_name(receiver: &syn::Expr) -> Option<String> {
        let syn::Expr::Field(expr_field) = receiver else {
            return None;
        };
        let syn::Expr::Path(expr_path) = &*expr_field.base else {
            return None;
        };
        if expr_path.path.segments.len() != 1 || expr_path.path.segments[0].ident != "self" {
            return None;
        }
        let syn::Member::Named(ident) = &expr_field.member else {
            return None;
        };
        Some(ident.to_string())
    }
}