Skip to main content

xtask/tasks/fmt/lints/
unused_deps.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Check for unused Rust dependencies
5//!
6//! Based upon <https://github.com/bnjbvr/cargo-machete>
7//! (license copied in source)
8
9// Copyright (c) 2022 Benjamin Bouvier
10//
11// Permission is hereby granted, free of charge, to any
12// person obtaining a copy of this software and associated
13// documentation files (the "Software"), to deal in the
14// Software without restriction, including without
15// limitation the rights to use, copy, modify, merge,
16// publish, distribute, sublicense, and/or sell copies of
17// the Software, and to permit persons to whom the Software
18// is furnished to do so, subject to the following
19// conditions:
20//
21// The above copyright notice and this permission notice
22// shall be included in all copies or substantial portions
23// of the Software.
24//
25// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
26// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
27// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
28// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
29// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
30// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
31// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
32// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
33// DEALINGS IN THE SOFTWARE.
34
35use super::Lint;
36use super::LintCtx;
37use super::Lintable;
38use grep_regex::RegexMatcher;
39use std::collections::BTreeMap;
40use std::collections::BTreeSet;
41use toml_edit::DocumentMut;
42
43pub struct UnusedDeps {
44    /// All dependency names declared in the workspace.
45    workspace_deps: BTreeSet<String>,
46    /// Workspace dependencies for which a usage has been found so far in source files.
47    workspace_found_deps: BTreeSet<String>,
48
49    /// All dependency names declared in the current crate.
50    crate_deps: BTreeSet<String>,
51    /// Dependencies for which a usage has been found so far in source files.
52    crate_found_deps: BTreeSet<String>,
53
54    /// Pre-compiled regex matchers keyed by dependency name, built once for
55    /// all workspace dependency names and extended lazily for any
56    /// non-workspace deps encountered in individual crates.
57    dep_matchers: BTreeMap<String, RegexMatcher>,
58
59    /// Whether we are linting only diffed files (workspace-level check is
60    /// unreliable in this mode because not all crates are visited).
61    only_diffed: bool,
62
63    /// Prebuilt grep searcher to avoid rebuilds
64    searcher: grep_searcher::Searcher,
65}
66
67impl Lint for UnusedDeps {
68    fn new(ctx: &LintCtx) -> Self {
69        UnusedDeps {
70            workspace_deps: BTreeSet::new(),
71            workspace_found_deps: BTreeSet::new(),
72            crate_deps: BTreeSet::new(),
73            crate_found_deps: BTreeSet::new(),
74            dep_matchers: BTreeMap::new(),
75            only_diffed: ctx.only_diffed,
76            searcher: grep_searcher::SearcherBuilder::new()
77                .line_number(false)
78                .build(),
79        }
80    }
81
82    fn enter_workspace(&mut self, content: &Lintable<DocumentMut>) {
83        // Extract workspace dependency names.
84        if let Some(deps) = content
85            .get("workspace")
86            .and_then(|w| w.get("dependencies"))
87            .and_then(|d| d.as_table_like())
88        {
89            // Pre-compile regex matchers for every workspace dependency.
90            for (dep_name, _) in deps.iter() {
91                self.workspace_deps.insert(dep_name.to_string());
92                self.dep_matchers
93                    .insert(dep_name.to_string(), compile_matcher(dep_name));
94            }
95        }
96    }
97
98    fn enter_crate(&mut self, content: &Lintable<DocumentMut>) {
99        const DEP_TABLE_NAMES: &[&str] =
100            &["dependencies", "build-dependencies", "dev-dependencies"];
101
102        self.crate_deps.clear();
103        self.crate_found_deps.clear();
104
105        for dep_table in DEP_TABLE_NAMES
106            .iter()
107            .flat_map(|s| content.get(s).map(|d| d.as_table_like().unwrap()))
108        {
109            for (dep_name, _) in dep_table.iter() {
110                self.crate_deps.insert(dep_name.to_string());
111                if !self.dep_matchers.contains_key(dep_name) {
112                    self.dep_matchers
113                        .insert(dep_name.to_string(), compile_matcher(dep_name));
114                }
115            }
116        }
117
118        // Target-specific dependencies.
119        if let Some(target) = content.get("target").map(|t| t.as_table_like().unwrap()) {
120            for target_table in target.iter().map(|(_, t)| t.as_table_like().unwrap()) {
121                for dep_table in DEP_TABLE_NAMES
122                    .iter()
123                    .flat_map(|s| target_table.get(s).map(|d| d.as_table_like().unwrap()))
124                {
125                    for (dep_name, _) in dep_table.iter() {
126                        self.crate_deps.insert(dep_name.to_string());
127                        if !self.dep_matchers.contains_key(dep_name) {
128                            self.dep_matchers
129                                .insert(dep_name.to_string(), compile_matcher(dep_name));
130                        }
131                    }
132                }
133            }
134        }
135    }
136
137    fn visit_file(&mut self, content: &mut Lintable<String>) {
138        if self.crate_found_deps.len() == self.crate_deps.len() {
139            return;
140        }
141
142        let unfound = self.crate_deps.difference(&self.crate_found_deps);
143        let mut found = Vec::new();
144        for looking in unfound {
145            let needle = looking.replace('-', "_");
146            // Fast rejection: if the identifier doesn't appear at all, the regex can't match.
147            if !content.contains(&needle) {
148                continue;
149            }
150            // ... run regex only for candidates that pass the pre-filter
151            let mut sink = StopAfterFirstMatch::new();
152            self.searcher
153                .search_slice(
154                    &self.dep_matchers[&**looking],
155                    content.as_bytes(),
156                    &mut sink,
157                )
158                .unwrap();
159            if sink.found {
160                found.push(looking.clone());
161            }
162        }
163
164        self.crate_found_deps.extend(found.iter().cloned());
165        self.workspace_found_deps.extend(found);
166    }
167
168    fn exit_crate(&mut self, content: &mut Lintable<DocumentMut>) {
169        exit_toml(content, &self.crate_deps, &self.crate_found_deps, false);
170    }
171
172    fn exit_workspace(&mut self, content: &mut Lintable<DocumentMut>) {
173        // When only diffed files are being checked we haven't visited every
174        // crate, so the used_workspace_deps set is incomplete.  Skip the
175        // workspace-level check to avoid false positives.
176        if self.only_diffed {
177            return;
178        }
179
180        exit_toml(
181            content,
182            &self.workspace_deps,
183            &self.workspace_found_deps,
184            true,
185        );
186    }
187}
188
189fn exit_toml(
190    content: &mut Lintable<DocumentMut>,
191    deps: &BTreeSet<String>,
192    found_deps: &BTreeSet<String>,
193    is_workspace: bool,
194) {
195    let top_key = if is_workspace { "workspace" } else { "package" };
196    // Extract per-manifest ignored list.
197    let ignored: BTreeSet<_> = content
198        .get(top_key)
199        .and_then(|p| p.get("metadata"))
200        .and_then(|m| m.get("xtask"))
201        .and_then(|x| x.get("unused-deps"))
202        .and_then(|u| u.get("ignored"))
203        .map(|arr| {
204            arr.as_array()
205                .unwrap()
206                .iter()
207                .map(|v| v.as_str().unwrap().to_string())
208                .collect()
209        })
210        .unwrap_or_default();
211
212    for ign in &ignored {
213        if !deps.contains(ign) {
214            let msg = format!("{ign} is ignored, but it's not being depended on");
215            content.fix(&msg, |doc| remove_from_ignored(doc, ign, is_workspace));
216        }
217    }
218
219    for dep in deps {
220        let found = found_deps.contains(&**dep);
221        let ignored = ignored.contains(&**dep);
222
223        match (found, ignored) {
224            (false, false) => {
225                content.fix(&format!("{dep} is unused"), |doc| {
226                    remove_from_deps(doc, dep, is_workspace)
227                });
228            }
229            (true, true) => {
230                content.fix(&format!("{dep} is ignored, but being used"), |doc| {
231                    remove_from_ignored(doc, dep, is_workspace)
232                });
233            }
234            _ => {}
235        }
236    }
237}
238
239fn compile_matcher(dep: &str) -> RegexMatcher {
240    let name = dep.replace('-', "_");
241    // Syntax documentation: https://docs.rs/regex/latest/regex/#syntax
242    //
243    // Breaking down this regular expression: given a line,
244    // - `use (::)?{name}(::|;| as)`: matches `use foo;`, `use foo::bar`, `use foo as bar;`, with
245    // an optional "::" in front of the crate's name.
246    // - `(?:[^:]|^|\W::)\b{name}::`: matches `foo::X`, but not `barfoo::X`. To ensure there's no polluting
247    //   prefix we add `(?:[^:]|^|\W::)\b`, meaning that the crate name must be prefixed by either:
248    //    * Not a `:` (therefore not a sub module)
249    //    * The start of a line
250    //    * Not a word character followed by `::` (to allow ::my_crate)
251    // - `extern crate {name}( |;)`: matches `extern crate foo`, or `extern crate foo as bar`.
252    // - `{name}` makes the match against the crate's name case insensitive
253    let regex =
254        format!(r#"use (::)?{name}(::|;| as)|(?:[^:]|^|\W::)\b{name}::|extern crate {name}( |;)"#);
255    RegexMatcher::new_line_matcher(&regex).unwrap()
256}
257
258/// Remove a dependency from all dep tables and its references in `[features]`.
259fn remove_from_deps(doc: &mut DocumentMut, dep_name: &str, is_workspace: bool) {
260    const DEP_TABLE_NAMES: &[&str] = &["dependencies", "build-dependencies", "dev-dependencies"];
261
262    if is_workspace {
263        // Remove from [workspace.dependencies]
264        let deps = doc["workspace"]["dependencies"]
265            .as_table_like_mut()
266            .unwrap();
267        deps.remove(dep_name);
268    } else {
269        // Remove from root-level dep tables.
270        for table_name in DEP_TABLE_NAMES {
271            if let Some(deps) = doc.get_mut(table_name).and_then(|d| d.as_table_like_mut()) {
272                deps.remove(dep_name);
273            }
274        }
275
276        // Remove from target-specific dep tables.
277        if let Some(target) = doc.get_mut("target").and_then(|t| t.as_table_like_mut()) {
278            let keys: Vec<String> = target.iter().map(|(k, _)| k.to_string()).collect();
279            for key in keys {
280                if let Some(target_table) = target.get_mut(&key).and_then(|t| t.as_table_like_mut())
281                {
282                    for table_name in DEP_TABLE_NAMES {
283                        if let Some(deps) = target_table
284                            .get_mut(table_name)
285                            .and_then(|d| d.as_table_like_mut())
286                        {
287                            deps.remove(dep_name);
288                        }
289                    }
290                }
291            }
292        }
293
294        // Remove references from [features].
295        if let Some(features) = doc.get_mut("features").and_then(|f| f.as_table_like_mut()) {
296            let dep_enable = format!("dep:{dep_name}");
297            let dep_feature_prefix = format!("{dep_name}/");
298            let feature_keys: Vec<String> = features.iter().map(|(k, _)| k.to_string()).collect();
299            for feature_key in feature_keys {
300                if let Some(arr) = features
301                    .get_mut(&feature_key)
302                    .and_then(|v| v.as_array_mut())
303                {
304                    arr.retain(|v| {
305                        if let Some(s) = v.as_str() {
306                            s != dep_enable && !s.starts_with(&dep_feature_prefix)
307                        } else {
308                            true
309                        }
310                    });
311                }
312            }
313        }
314    }
315}
316
317/// Remove a name from the `ignored` metadata array
318fn remove_from_ignored(doc: &mut DocumentMut, dep_name: &str, is_workspace: bool) {
319    let top_key = if is_workspace { "workspace" } else { "package" };
320    let ignored_array = doc[top_key]["metadata"]["xtask"]["unused-deps"]["ignored"]
321        .as_array_mut()
322        .unwrap();
323    ignored_array.retain(|v| v.as_str() != Some(dep_name));
324}
325
326struct StopAfterFirstMatch {
327    found: bool,
328}
329
330impl StopAfterFirstMatch {
331    fn new() -> Self {
332        Self { found: false }
333    }
334}
335
336impl grep_searcher::Sink for StopAfterFirstMatch {
337    type Error = Box<dyn std::error::Error>;
338
339    fn matched(
340        &mut self,
341        _searcher: &grep_searcher::Searcher,
342        mat: &grep_searcher::SinkMatch<'_>,
343    ) -> Result<bool, Self::Error> {
344        let mat = mat.bytes().trim_ascii();
345
346        if mat.starts_with(b"//")
347            && !(mat.starts_with(b"/// # use")
348                || mat.starts_with(b"/// use")
349                || mat.starts_with(b"//! # use")
350                || mat.starts_with(b"//! use"))
351        {
352            // Continue if seeing what resembles a comment or an inner doc
353            // comment.
354            // Certain exceptions for outer doc comments (`///`) because they may contain code
355            // examples that use dependencies, and skipping them could cause us to miss usages
356            // relevant for dependency detection. Unfortunately we can't check whether the example
357            // is within a code snippet without actually parsing the code.
358            return Ok(true);
359        }
360
361        // Otherwise, we've found it: mark to true, and return false to indicate that we can stop
362        // searching.
363        self.found = true;
364        Ok(false)
365    }
366}