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]]` 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
171/// Below the size where the ceiling allows one unused item the share says
172/// nothing, and each unused item is already reported unless allowed with a reason.
173fn 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}