Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ ignore = ["main", "some_macro_target"]
tests_count_as_uses = false # an item only its tests call is dead weight

[dependencies]
include_dev = true # opt in to test/benchmark-only dependency cost
ignore_unused = ["thiserror"] # reached only through a derive macro

[ui]
Expand Down
14 changes: 8 additions & 6 deletions crates/tinyanalyzer-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,16 @@ their callers may not be in this workspace at all.

**Unused dependencies** (`deps`) are dependencies no source file *names*. A crate
reached only through a derive macro or a linker side effect has no `use` naming
it, which is what `ignore_unused` is for.
it, which is what `ignore_unused` is for. Development dependencies and their
transitive packages are excluded by default so the graph describes production
cost; set `dependencies.include_dev = true` to include test and benchmark tooling.

**Everything else** — line counts, item counts, function lengths, nesting, the
dependency graph — is exact. The graph in particular comes from `cargo metadata`
rather than from re-parsing manifests, because features, optional dependencies,
platform-specific edges, and version unification are decided by the resolver, and
a tool that re-implements any of them will disagree with the build it is
describing.
dependency graph — is exact. The graph in particular comes from Cargo's metadata
and production tree rather than from re-parsing manifests, because features,
optional dependencies, platform-specific edges, and version unification are
decided by the resolver, and a tool that re-implements any of them will disagree
with the build it is describing.

## Example

Expand Down
2 changes: 2 additions & 0 deletions crates/tinyanalyzer-core/src/config/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ fn the_defaults_are_the_documented_ones() {
assert!(!config.scan.follow_symlinks);
assert!(config.dead_code.enabled);
assert!(!config.dead_code.tests_count_as_uses);
assert!(config.dependencies.enabled);
assert!(!config.dependencies.include_dev);
assert_eq!(config.ui.start_view, StartView::Overview);
assert!(!config.ui.hide_tests);
assert_eq!(config.ui.table_rows, 20);
Expand Down
8 changes: 6 additions & 2 deletions crates/tinyanalyzer-core/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,11 @@ pub struct DependencyConfig {
/// Turning this off makes the analysis pure filesystem work, which is what
/// you want against a tree that does not resolve.
pub enabled: bool,
/// Whether development and build dependencies are included in the graph.
/// Whether development dependencies are included in the graph.
///
/// Off by default so dependency cost describes production builds. Build
/// dependencies remain included because Cargo needs them to compile a
/// production target.
pub include_dev: bool,
/// Crate names never reported as unused, however unreferenced they look.
///
Expand All @@ -214,7 +218,7 @@ impl Default for DependencyConfig {
fn default() -> Self {
Self {
enabled: true,
include_dev: true,
include_dev: false,
ignore_unused: Vec::new(),
}
}
Expand Down
221 changes: 181 additions & 40 deletions crates/tinyanalyzer-core/src/deps/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
//! Reading the resolved dependency graph.
//!
//! The graph comes from `cargo metadata` rather than from re-parsing manifests.
//! That is the whole design decision of this module: features, optional
//! dependencies, platform-specific edges, and version unification are decided
//! by the resolver, and a tool that re-implements any of them will disagree
//! with the build it is describing. Being slower and correct beats being
//! instant and plausible.
//! The graph comes from Cargo rather than from re-parsing manifests. Metadata
//! supplies package identities and every resolved edge; Cargo's production tree
//! supplies the normal/build-only feature context when development dependencies
//! are excluded. Features, optional dependencies, platform-specific edges, and
//! version unification remain Cargo's decisions rather than this module's.
//!
//! What this module adds on top of cargo's answer is the arithmetic cargo does
//! not do:
Expand All @@ -32,6 +31,13 @@ use cargo_metadata::MetadataCommand;
use ignore::WalkBuilder;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path;
use std::process::Command;

#[derive(Debug)]
struct ProductionResolution {
package_ids: BTreeSet<String>,
features: BTreeMap<String, Vec<String>>,
}

/// Which crate names each workspace member's source files mention.
///
Expand All @@ -53,14 +59,10 @@ pub fn analyze(
references: &CrateReferences,
) -> Result<DependencyReport> {
let root = root.as_ref();

let metadata = MetadataCommand::new()
.manifest_path(root.join("Cargo.toml"))
.exec()
.map_err(|source| Error::CargoMetadata {
root: root.to_path_buf(),
message: source.to_string(),
})?;
let metadata = resolved_metadata(root)?;
let production = (!config.include_dev)
.then(|| production_resolution(root, &metadata))
.transpose()?;

let resolve = metadata
.resolve
Expand All @@ -76,37 +78,21 @@ pub fn analyze(
.map(ToString::to_string)
.collect();

let mut edges = Vec::new();
let mut adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();

for node in &resolve.nodes {
let from = node.id.to_string();

for dep in &node.deps {
let kinds = edge_kinds(dep, config.include_dev);
if kinds.is_empty() {
continue;
}

let to = dep.pkg.to_string();
adjacency.entry(from.clone()).or_default().push(to.clone());

for kind in kinds {
edges.push(DependencyEdge {
from: from.clone(),
to: to.clone(),
kind,
});
}
}
}
let (mut edges, adjacency) = resolved_edges(resolve, config.include_dev, production.as_ref());

let depths = shortest_depths(&members, &adjacency);
let direct = direct_dependencies(&members, &adjacency);
let member_ids: Vec<String> = members.iter().cloned().collect();
let mut included = reachable_from(&member_ids, &adjacency);
Comment thread
senamakel marked this conversation as resolved.
included.extend(members.iter().cloned());
edges.retain(|edge| included.contains(&edge.from) && included.contains(&edge.to));

let mut packages = Vec::new();
for node in &resolve.nodes {
let id = node.id.to_string();
if !included.contains(&id) {
continue;
Comment thread
senamakel marked this conversation as resolved.
}
Comment thread
senamakel marked this conversation as resolved.
let Some(package) = metadata.packages.iter().find(|entry| entry.id == node.id) else {
continue;
};
Expand All @@ -119,7 +105,10 @@ pub fn analyze(
is_root_package: package.manifest_path.as_std_path() == root.join("Cargo.toml"),
is_direct: direct.contains(&id),
kinds: kinds_for(&id, &edges),
features: node.features.iter().map(ToString::to_string).collect(),
features: production.as_ref().map_or_else(
|| node.features.iter().map(ToString::to_string).collect(),
|resolution| resolution.features.get(&id).cloned().unwrap_or_default(),
),
available_features: package.features.keys().map(ToString::to_string).collect(),
transitive_count: reachable.len(),
exclusive_count: exclusive,
Expand Down Expand Up @@ -161,14 +150,141 @@ pub fn analyze(

Ok(DependencyReport {
duplicates: find_duplicates(&packages),
unused: find_unused(&metadata, &members, config, references),
unused: find_unused(&metadata, &members, &adjacency, config, references),
packages,
edges,
external_packages,
max_depth,
})
}

/// Asks Cargo for the workspace graph, preserving its diagnostic on failure.
fn resolved_metadata(root: &Path) -> Result<cargo_metadata::Metadata> {
MetadataCommand::new()
.manifest_path(root.join("Cargo.toml"))
.exec()
.map_err(|source| Error::CargoMetadata {
root: root.to_path_buf(),
message: source.to_string(),
})
}

/// Resolves the package and feature set Cargo uses for production targets.
fn production_resolution(
root: &Path,
metadata: &cargo_metadata::Metadata,
) -> Result<ProductionResolution> {
let output = Command::new("cargo")
.args([
"tree",
"--workspace",
"--target",
"all",
"--edges",
"normal,build",
"--prefix",
"none",
"--format",
"{p}|{f}",
"--manifest-path",
])
.arg(root.join("Cargo.toml"))
.output()
.map_err(|source| Error::CargoMetadata {
root: root.to_path_buf(),
message: source.to_string(),
})?;

if !output.status.success() {
return Err(Error::CargoMetadata {
root: root.to_path_buf(),
message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
});
}

let stdout = String::from_utf8_lossy(&output.stdout);
let mut features_by_key: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
for line in stdout.lines() {
let Some((package, features)) = line.split_once('|') else {
continue;
};
let Some(key) = tree_package_key(package) else {
continue;
};
features_by_key.entry(key).or_default().extend(
features
.split(',')
.filter(|feature| !feature.is_empty())
.map(ToOwned::to_owned),
);
}

let mut package_ids = BTreeSet::new();
let mut resolved_features = BTreeMap::new();
for package in &metadata.packages {
let key = (package.name.to_string(), package.version.to_string());
let Some(features) = features_by_key.get(&key) else {
continue;
};
let id = package.id.to_string();
package_ids.insert(id.clone());
resolved_features.insert(id, features.iter().cloned().collect());
}

Ok(ProductionResolution {
package_ids,
features: resolved_features,
})
}

/// Extracts the package name and version from Cargo's controlled tree format.
fn tree_package_key(package: &str) -> Option<(String, String)> {
let mut fields = package.split_whitespace();
let name = fields.next()?.to_owned();
let version = fields.next()?.strip_prefix('v')?.to_owned();
Some((name, version))
}

/// Builds the closed edge list and adjacency map for one Cargo resolution.
fn resolved_edges(
resolve: &cargo_metadata::Resolve,
include_dev: bool,
production: Option<&ProductionResolution>,
) -> (Vec<DependencyEdge>, BTreeMap<String, Vec<String>>) {
let mut edges = Vec::new();
let mut adjacency: BTreeMap<String, Vec<String>> = BTreeMap::new();

for node in &resolve.nodes {
let from = node.id.to_string();

for dep in &node.deps {
let to = dep.pkg.to_string();
if production.is_some_and(|resolution| {
!resolution.package_ids.contains(&from) || !resolution.package_ids.contains(&to)
}) {
Comment on lines +262 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve production edge identities

When an optional dependency activated only by a development feature is also present in production through another package, both endpoints occur in package_ids, so this filter copies the development-activated metadata edge into the production graph. For example, if app normally depends on both engine and leaf, while its dev declaration enables an engine feature that adds optional engine -> leaf, the production resolution has no engine -> leaf edge but this code retains it, inflating engine's reach/transitive metrics and potentially reporting that optional declaration as unused production weight. Fresh evidence after the attempted fix is that resolved_edges now checks only endpoint membership, not whether the production tree contains the edge itself; retain production edge identities as well as package IDs.

AGENTS.md reference: AGENTS.md:L45-L45

Useful? React with 👍 / 👎.

continue;
}

let kinds = edge_kinds(dep, include_dev);
if kinds.is_empty() {
continue;
}

adjacency.entry(from.clone()).or_default().push(to.clone());

for kind in kinds {
edges.push(DependencyEdge {
from: from.clone(),
to: to.clone(),
kind,
});
}
}
}

(edges, adjacency)
}

/// Measures the checked-out source Cargo would compile for one package.
fn package_source_bytes(root: &Path) -> u64 {
WalkBuilder::new(root)
Expand Down Expand Up @@ -363,6 +479,7 @@ fn find_duplicates(packages: &[PackageNode]) -> Vec<DuplicateVersions> {
fn find_unused(
metadata: &cargo_metadata::Metadata,
members: &BTreeSet<String>,
adjacency: &BTreeMap<String, Vec<String>>,
config: &DependencyConfig,
references: &CrateReferences,
) -> Vec<UnusedDependency> {
Expand All @@ -388,6 +505,10 @@ fn find_unused(
};

for dependency in &package.dependencies {
if !dependency_is_resolved(package, dependency, adjacency, metadata) {
continue;
}

let kind = match dependency.kind {
cargo_metadata::DependencyKind::Normal => DependencyKind::Normal,
cargo_metadata::DependencyKind::Development if config.include_dev => {
Expand Down Expand Up @@ -424,6 +545,26 @@ fn find_unused(
unused
}

/// Whether Cargo retained this declaration in the selected dependency graph.
fn dependency_is_resolved(
package: &cargo_metadata::Package,
dependency: &cargo_metadata::Dependency,
adjacency: &BTreeMap<String, Vec<String>>,
metadata: &cargo_metadata::Metadata,
) -> bool {
adjacency
.get(&package.id.to_string())
.into_iter()
.flatten()
.any(|id| {
metadata.packages.iter().any(|candidate| {
candidate.id.to_string() == *id
&& candidate.name == dependency.name
&& dependency.req.matches(&candidate.version)
})
})
}

/// Folds a manifest crate name into the identifier a `use` statement writes.
#[must_use]
pub fn normalize_crate_name(name: &str) -> String {
Expand Down
Loading