1use std::collections::BTreeMap;
2
3use sofie_model::usage::{Mentions, PublicItem};
4use sofie_model::{Crate, Dependency, ModelError, Source};
5
6use super::allowances::{AllowList, Allowed};
7use super::manifest::declaration_line;
8use super::{Finding, Rule};
9use crate::workspace::read_text;
10
11pub const MAX_UNUSED_PUBLIC_RATIO: f64 = 0.02;
13
14struct DeadCode<'model> {
15 krate: &'model Crate,
16 public_items: usize,
17 unused_items: Vec<(&'model Source, &'model PublicItem)>,
18 unused_dependencies: Vec<&'model Dependency>,
19}
20
21pub fn check_dead_code(
22 checked: &[&Crate],
23 context: &[&Crate],
24 allowed: &AllowList,
25 ceiling: f64,
26) -> Result<Vec<Finding>, ModelError> {
27 let mut mentions = Mentions::default();
28 for source in checked
29 .iter()
30 .chain(context)
31 .flat_map(|krate| all_files(krate))
32 {
33 mentions.add(&source.mentions);
34 }
35 let definitions = definitions(checked);
36 let dead_code: Vec<DeadCode> = checked
37 .iter()
38 .map(|krate| dead_code_of(krate, &mentions, &definitions))
39 .collect();
40 let mut findings = Vec::new();
41 for crate_dead_code in &dead_code {
42 findings.extend(item_findings(crate_dead_code, allowed));
43 findings.extend(dependency_findings(crate_dead_code, allowed)?);
44 findings.extend(ratio_finding(crate_dead_code, ceiling));
45 }
46 findings.extend(stale_allowances(&dead_code, allowed));
47 Ok(findings)
48}
49
50fn all_files(krate: &Crate) -> impl Iterator<Item = &Source> {
51 krate.sources.iter().chain(&krate.tests_and_examples)
52}
53
54fn definitions<'model>(checked: &[&'model Crate]) -> BTreeMap<&'model str, usize> {
57 let mut definitions = BTreeMap::new();
58 for source in checked.iter().flat_map(|krate| &krate.sources) {
59 for item in &source.public_items {
60 *definitions.entry(item.name.as_str()).or_default() += 1;
61 }
62 }
63 definitions
64}
65
66fn dead_code_of<'model>(
67 krate: &'model Crate,
68 mentions: &Mentions,
69 definitions: &BTreeMap<&str, usize>,
70) -> DeadCode<'model> {
71 let generated = krate.gen_file();
73 let items: Vec<(&Source, &PublicItem)> = krate
74 .sources
75 .iter()
76 .filter(|source| source.file != generated)
77 .flat_map(|source| source.public_items.iter().map(move |item| (source, item)))
78 .collect();
79 DeadCode {
80 krate,
81 public_items: items.len(),
82 unused_items: items
83 .into_iter()
84 .filter(|(_, item)| {
85 mentions.count(&item.name)
86 <= definitions
87 .get(item.name.as_str())
88 .copied()
89 .unwrap_or_default()
90 })
91 .collect(),
92 unused_dependencies: krate
93 .dependencies
94 .iter()
95 .filter(|dependency| !names_crate(krate, &dependency.crate_name))
96 .collect(),
97 }
98}
99
100fn names_crate(krate: &Crate, name: &str) -> bool {
102 krate.sources.iter().any(|source| {
103 source.mentions.count(name) > 0
104 || source
105 .paths
106 .iter()
107 .any(|path| path.segments.first().is_some_and(|first| first == name))
108 })
109}
110
111fn item_findings<'model>(
112 dead_code: &'model DeadCode<'model>,
113 allowed: &'model AllowList,
114) -> impl Iterator<Item = Finding> + 'model {
115 let package = &dead_code.krate.package;
116 dead_code
117 .unused_items
118 .iter()
119 .filter(|(_, item)| !allowed.allows(Allowed::Item, package, &item.name))
120 .map(move |(source, item)| Finding {
121 rule: Rule::UnusedItem,
122 file: source.file.clone(),
123 line: item.line,
124 message: format!(
125 "`{}` is `pub` but nothing names it outside its definition, so it is dead code",
126 item.name
127 ),
128 fix: format!(
129 "delete it, or list it in dead-code-allow.toml as `[[item]]` with \
130 `package = \"{package}\"`, `name = \"{}\"` and a `reason` it stays",
131 item.name
132 ),
133 })
134}
135
136fn dependency_findings(
137 dead_code: &DeadCode<'_>,
138 allowed: &AllowList,
139) -> Result<Vec<Finding>, ModelError> {
140 let krate = dead_code.krate;
141 let unused: Vec<&Dependency> = dead_code
142 .unused_dependencies
143 .iter()
144 .copied()
145 .filter(|dependency| {
146 !allowed.allows(Allowed::Dependency, &krate.package, &dependency.package)
147 })
148 .collect();
149 if unused.is_empty() {
150 return Ok(Vec::new());
151 }
152 let manifest = krate.path.join("Cargo.toml");
153 let text = read_text(&manifest)?;
154 Ok(unused
155 .into_iter()
156 .map(|dependency| Finding {
157 rule: Rule::UnusedDependency,
158 file: manifest.clone(),
159 line: declaration_line(&text, &dependency.package),
160 message: format!(
161 "`{}` is in [dependencies] but no source of `{}` names `{}`",
162 dependency.package, krate.package, dependency.crate_name
163 ),
164 fix: "remove it, move it to [dev-dependencies] if only tests use it, or list it in \
165 dead-code-allow.toml as `[[dependency]]` with the reason it stays"
166 .to_owned(),
167 })
168 .collect())
169}
170
171fn ratio_finding(dead_code: &DeadCode<'_>, ceiling: f64) -> Option<Finding> {
174 if crate::count_as_f64(dead_code.public_items) * ceiling < 1.0 {
175 return None;
176 }
177 let unused = dead_code.unused_items.len();
178 let ratio = crate::count_as_f64(unused) / crate::count_as_f64(dead_code.public_items.max(1));
179 if ratio <= ceiling {
180 return None;
181 }
182 Some(Finding {
183 rule: Rule::UnusedRatio,
184 file: dead_code.krate.path.join("Cargo.toml"),
185 line: 1,
186 message: format!(
187 "{unused} of {} `pub` items of `{}` are unused, allowed ones included: {:.1}% is above \
188 the ceiling of {:.1}%",
189 dead_code.public_items,
190 dead_code.krate.package,
191 ratio * 100.0,
192 ceiling * 100.0
193 ),
194 fix: "delete unused items until the share is under the ceiling".to_owned(),
195 })
196}
197
198fn stale_allowances(dead_code: &[DeadCode<'_>], allowed: &AllowList) -> Vec<Finding> {
199 allowed
200 .allowances
201 .iter()
202 .filter(|allowance| {
203 !dead_code.iter().any(|crate_dead_code| {
204 crate_dead_code.krate.package == allowance.package
205 && match allowance.kind {
206 Allowed::Item => crate_dead_code
207 .unused_items
208 .iter()
209 .any(|(_, item)| item.name == allowance.name),
210 Allowed::Dependency => crate_dead_code
211 .unused_dependencies
212 .iter()
213 .any(|dependency| dependency.package == allowance.name),
214 }
215 })
216 })
217 .map(|allowance| Finding {
218 rule: Rule::StaleAllowance,
219 file: allowed.file.clone(),
220 line: allowance.line,
221 message: format!(
222 "`{}` of `{}` is allowed as unused, but it is used or no longer exists",
223 allowance.name, allowance.package
224 ),
225 fix: "delete this entry".to_owned(),
226 })
227 .collect()
228}