cargo-treemap 0.1.0

Interactive treemap analyzer for Rust binary size and dependency API usage — like webpack-bundle-analyzer, for cargo.
//! The "how dependent are you" metric: for each direct dependency, what fraction of its
//! public API you actually use, plus a value-to-size verdict.
//!
//! Surface = public functions from the dep's rustdoc JSON (via `public-api`, which resolves
//! methods/trait-impls the raw `visibility` field hides). Used = those whose names appear as
//! symbols attributed to the dep in the analyzed binary. This undercounts when the profile
//! inlines cross-crate calls (release), analyze a debug build for a fuller count. Proc-macro
//! deps emit no runtime symbols, so they're tagged unmeasurable rather than flagged.

use crate::artifact::{self, SymClass};
use crate::attribute::{Attributor, RlibMap};
use crate::builder::BuildOutput;
use crate::cli::TreemapArgs;
use crate::model::{artifact_nodes, crate_bin_totals, Node};
use crate::parity::lib_target_name;
use crate::util::{split_path, strip_generics};
use anyhow::{Context, Result};
use cargo_metadata::{DependencyKind, Metadata, Package, TargetKind};
use serde::Serialize;
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

#[derive(Serialize)]
pub struct DepValue {
    pub name: String,
    pub surface_fns: u32,
    pub total_public: u32,
    pub used_fns: u32,
    pub ratio: Option<f32>,
    pub size_text: u64,
    pub size_data: u64,
    pub referenced: bool,
    pub is_proc_macro: bool,
    pub rustdoc_ok: bool,
    pub flag_unused: bool,
    pub flag_low_value: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
}

/// Per top-level crate node, its attributed text/data sizes.
fn collect_sizes(root: &Node) -> HashMap<String, (u64, u64)> {
    crate_bin_totals(root)
        .into_iter()
        .map(|(k, (t, d, _, _))| (k, (t, d)))
        .collect()
}

/// Per top-level crate node, the set of function (text-bearing) leaf names, from the tree.
fn collect_tree_fns(root: &Node) -> HashMap<String, HashSet<String>> {
    let mut out: HashMap<String, HashSet<String>> = HashMap::new();
    for art in artifact_nodes(root) {
        for c in &art.groups {
            collect_leaf_fns(c, out.entry(c.label.clone()).or_default());
        }
    }
    out
}

fn collect_leaf_fns(node: &Node, out: &mut HashSet<String>) {
    if node.groups.is_empty() {
        if node.text > 0 {
            out.insert(node.label.clone());
        }
    } else {
        for c in &node.groups {
            collect_leaf_fns(c, out);
        }
    }
}

/// Per-crate function-name sets read directly from a build's artifacts (used by `--accurate`:
/// an opt-level-0 build whose symbols survive cross-crate inlining).
pub fn crate_fns(build: &BuildOutput, split_std: bool) -> Result<HashMap<String, HashSet<String>>> {
    let rlib_map = RlibMap::build(&build.rlibs);
    let attr = Attributor::new(&rlib_map, &build.crate_names, split_std);
    let mut out: HashMap<String, HashSet<String>> = HashMap::new();
    for path in &build.artifacts {
        let parsed = artifact::parse(path, false)?;
        for sym in &parsed.symbols {
            if sym.class != SymClass::Text {
                continue;
            }
            let a = attr.attribute(&sym.name, sym.address, None);
            let segs = split_path(&a.demangled);
            let leaf = match segs.last() {
                Some(s) => strip_generics(s),
                None => continue,
            };
            out.entry(a.crate_name).or_default().insert(leaf);
        }
    }
    Ok(out)
}

/// Public function names + total public item count from a dependency's rustdoc JSON.
fn rustdoc_surface(json: &std::path::Path) -> Result<(HashSet<String>, u32)> {
    use public_api::tokens::Token;
    let api = public_api::Builder::from_rustdoc_json(json)
        .build()
        .with_context(|| format!("parsing {}", json.display()))?;
    let mut fns = HashSet::new();
    let mut total = 0u32;
    for item in api.items() {
        total += 1;
        // Presence of a Function token is the reliable "this item is a callable fn/method".
        for tok in item.tokens() {
            if let Token::Function(n) = tok {
                fns.insert(n.clone());
                break;
            }
        }
    }
    Ok((fns, total))
}

/// Generate rustdoc JSON for one dependency and return the file path.
fn gen_rustdoc(args: &TreemapArgs, meta: &Metadata, spec: &str, lib_name: &str) -> Result<PathBuf> {
    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    let mut cmd = std::process::Command::new(&cargo);
    cmd.env("RUSTC_BOOTSTRAP", "1") // allow the unstable JSON output on stable toolchains
        .arg("rustdoc")
        .arg("-q")
        .arg("-p")
        .arg(spec)
        .arg("--lib"); // rustdoc extra args require a single target
    if let Some(t) = &args.target {
        cmd.arg("--target").arg(t);
    }
    if let Some(mp) = &args.manifest.manifest_path {
        cmd.arg("--manifest-path").arg(mp);
    }
    cmd.arg("--")
        .arg("-Z")
        .arg("unstable-options")
        .arg("--output-format")
        .arg("json");
    cmd.stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null());
    let status = cmd.status().context("spawning cargo rustdoc")?;
    if !status.success() {
        anyhow::bail!("cargo rustdoc failed for {spec}");
    }

    let td = &meta.target_directory;
    let file = format!("{lib_name}.json");
    let candidates = [
        td.join("doc").join(&file),
        match &args.target {
            Some(t) => td.join(t).join("doc").join(&file),
            None => td.join("doc").join(&file),
        },
    ];
    candidates
        .iter()
        .find(|p| p.exists())
        .map(|p| p.clone().into_std_path_buf())
        .with_context(|| format!("rustdoc JSON for {spec} not found under {td}/doc"))
}

/// Words that look like a use of `crate_ident` in workspace source (rough machete signal).
fn scan_referenced(meta: &Metadata, idents: &HashSet<String>) -> HashSet<String> {
    let mut found = HashSet::new();
    let root = meta.workspace_root.as_std_path();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(rd) = std::fs::read_dir(&dir) else {
            continue;
        };
        for ent in rd.flatten() {
            let p = ent.path();
            if p.is_dir() {
                let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
                if name != "target" && name != ".git" {
                    stack.push(p);
                }
            } else if p.extension().and_then(|s| s.to_str()) == Some("rs") {
                if let Ok(src) = std::fs::read_to_string(&p) {
                    for id in idents {
                        if !found.contains(id) && mentions_ident(&src, id) {
                            found.insert(id.clone());
                        }
                    }
                }
            }
        }
    }
    found
}

fn mentions_ident(src: &str, ident: &str) -> bool {
    let mut from = 0;
    while let Some(rel) = src[from..].find(ident) {
        let i = from + rel;
        let before = src[..i].chars().next_back();
        let after = src[i + ident.len()..].chars().next();
        let ok_before = before.is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
        let ok_after = after.is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
        if ok_before && ok_after {
            return true;
        }
        from = i + ident.len();
    }
    false
}

pub fn analyze(
    meta: &Metadata,
    selected: &[&Package],
    root: &Node,
    used_override: Option<&HashMap<String, HashSet<String>>>,
    args: &TreemapArgs,
) -> Result<Vec<DepValue>> {
    // Sizes always come from the analyzed (real-profile) build; the used-set is either that
    // build's symbols (default) or an override from the opt-0 --accurate build.
    let sizes = collect_sizes(root);
    let tree_fns;
    let fns = match used_override {
        Some(m) => m,
        None => {
            tree_fns = collect_tree_fns(root);
            &tree_fns
        }
    };
    let by_name: HashMap<String, &Package> =
        meta.packages.iter().map(|p| (p.name.to_string(), p)).collect();

    // Direct normal dependencies of the selected packages.
    let mut specs: Vec<String> = Vec::new();
    let mut seen = HashSet::new();
    for pkg in selected {
        for dep in &pkg.dependencies {
            if dep.kind == DependencyKind::Normal && seen.insert(dep.name.clone()) {
                specs.push(dep.name.clone());
            }
        }
    }
    specs.sort();

    // machete-style referenced signal over all candidate lib idents at once.
    let idents: HashSet<String> = specs
        .iter()
        .filter_map(|s| by_name.get(s).and_then(|p| lib_target_name(p)))
        .collect();
    let referenced = scan_referenced(meta, &idents);

    let mut out = Vec::new();
    for spec in &specs {
        let Some(pkg) = by_name.get(spec) else {
            continue;
        };
        let Some(lib) = lib_target_name(pkg) else {
            continue;
        };
        let is_proc_macro = pkg
            .targets
            .iter()
            .any(|t| t.kind.contains(&TargetKind::ProcMacro));
        let (size_text, size_data) = sizes.get(&lib).copied().unwrap_or((0, 0));
        let used_set = fns.get(&lib);
        let is_ref = referenced.contains(&lib);

        if is_proc_macro {
            out.push(DepValue {
                name: lib,
                surface_fns: 0,
                total_public: 0,
                used_fns: 0,
                ratio: None,
                size_text,
                size_data,
                referenced: is_ref,
                is_proc_macro: true,
                rustdoc_ok: false,
                flag_unused: false,
                flag_low_value: false,
                note: Some("proc-macro: emits no runtime symbols (unmeasurable)".to_string()),
            });
            continue;
        }

        eprintln!("cargo-treemap: api-usage: documenting {spec}");
        let (surface, total, rustdoc_ok, mut note) =
            match gen_rustdoc(args, meta, spec, &lib).and_then(|j| rustdoc_surface(&j)) {
                Ok((fns, total)) => (fns, total, true, None),
                Err(e) => (HashSet::new(), 0, false, Some(format!("rustdoc unavailable: {e:#}"))),
            };

        let used = used_set.map_or(0, |set| surface.intersection(set).count() as u32);
        let ratio = if surface.is_empty() {
            // rustdoc succeeded but the crate exposes no measurable free functions, a
            // trait/derive-based API (serde) or a re-export facade (clap re-exports clap_builder).
            if rustdoc_ok {
                note = Some(
                    "no free functions in public API (trait/derive/facade crate)".to_string(),
                );
            }
            None
        } else {
            Some(used as f32 / surface.len() as f32)
        };

        out.push(DepValue {
            name: lib,
            surface_fns: surface.len() as u32,
            total_public: total,
            used_fns: used,
            ratio,
            size_text,
            size_data,
            referenced: is_ref,
            is_proc_macro: false,
            rustdoc_ok,
            flag_unused: !is_ref && size_text + size_data == 0,
            flag_low_value: false, // set below once the size distribution is known
            note,
        });
    }

    // Low-value/high-size: big attributed size but a tiny used fraction.
    let mut sizes: Vec<u64> = out.iter().map(|d| d.size_text).filter(|s| *s > 0).collect();
    sizes.sort_unstable();
    let p75 = sizes
        .get(sizes.len().saturating_sub(1).min(sizes.len() * 3 / 4))
        .copied()
        .unwrap_or(0);
    let threshold = 0.15;
    for d in &mut out {
        if !d.is_proc_macro && d.referenced && d.size_text >= p75 && p75 > 0 {
            if let Some(r) = d.ratio {
                if r <= threshold {
                    d.flag_low_value = true;
                }
            }
        }
    }

    out.sort_by_key(|d| Reverse(d.size_text + d.size_data));
    Ok(out)
}