Skip to main content

sofie_check/rules/
dead_code.rs

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
11/// Lower it as dead code is deleted; never raise it.
12pub 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
54/// How many tracked items define each name: their own definition is a
55/// mention that is not a use.
56fn 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    // What `sofie gen` writes is exported for others to call, used or not.
72    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
100/// A re-export counts: the dependency is what makes it compile.
101fn 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]]` of package \
130                 `{package}` with the reason it stays"
131            ),
132        })
133}
134
135fn dependency_findings(
136    dead_code: &DeadCode<'_>,
137    allowed: &AllowList,
138) -> Result<Vec<Finding>, ModelError> {
139    let krate = dead_code.krate;
140    let unused: Vec<&Dependency> = dead_code
141        .unused_dependencies
142        .iter()
143        .copied()
144        .filter(|dependency| {
145            !allowed.allows(Allowed::Dependency, &krate.package, &dependency.package)
146        })
147        .collect();
148    if unused.is_empty() {
149        return Ok(Vec::new());
150    }
151    let manifest = krate.path.join("Cargo.toml");
152    let text = read_text(&manifest)?;
153    Ok(unused
154        .into_iter()
155        .map(|dependency| Finding {
156            rule: Rule::UnusedDependency,
157            file: manifest.clone(),
158            line: declaration_line(&text, &dependency.package),
159            message: format!(
160                "`{}` is in [dependencies] but no source of `{}` names `{}`",
161                dependency.package, krate.package, dependency.crate_name
162            ),
163            fix: "remove it, move it to [dev-dependencies] if only tests use it, or list it in \
164                  dead-code-allow.toml as `[[dependency]]` with the reason it stays"
165                .to_owned(),
166        })
167        .collect())
168}
169
170/// Below the size where the ceiling allows one unused item the share says
171/// nothing, and each unused item is already reported unless allowed with a reason.
172fn ratio_finding(dead_code: &DeadCode<'_>, ceiling: f64) -> Option<Finding> {
173    if crate::count_as_f64(dead_code.public_items) * ceiling < 1.0 {
174        return None;
175    }
176    let unused = dead_code.unused_items.len();
177    let ratio = crate::count_as_f64(unused) / crate::count_as_f64(dead_code.public_items.max(1));
178    if ratio <= ceiling {
179        return None;
180    }
181    Some(Finding {
182        rule: Rule::UnusedRatio,
183        file: dead_code.krate.path.join("Cargo.toml"),
184        line: 1,
185        message: format!(
186            "{unused} of {} `pub` items of `{}` are unused, allowed ones included: {:.1}% is above \
187             the ceiling of {:.1}%",
188            dead_code.public_items,
189            dead_code.krate.package,
190            ratio * 100.0,
191            ceiling * 100.0
192        ),
193        fix: "delete unused items until the share is under the ceiling".to_owned(),
194    })
195}
196
197fn stale_allowances(dead_code: &[DeadCode<'_>], allowed: &AllowList) -> Vec<Finding> {
198    allowed
199        .allowances
200        .iter()
201        .filter(|allowance| {
202            !dead_code.iter().any(|crate_dead_code| {
203                crate_dead_code.krate.package == allowance.package
204                    && match allowance.kind {
205                        Allowed::Item => crate_dead_code
206                            .unused_items
207                            .iter()
208                            .any(|(_, item)| item.name == allowance.name),
209                        Allowed::Dependency => crate_dead_code
210                            .unused_dependencies
211                            .iter()
212                            .any(|dependency| dependency.package == allowance.name),
213                    }
214            })
215        })
216        .map(|allowance| Finding {
217            rule: Rule::StaleAllowance,
218            file: allowed.file.clone(),
219            line: allowance.line,
220            message: format!(
221                "`{}` of `{}` is allowed as unused, but it is used or no longer exists",
222                allowance.name, allowance.package
223            ),
224            fix: "delete this entry".to_owned(),
225        })
226        .collect()
227}