Skip to main content

xtask/tasks/fmt/lints/
orphaned_rs.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Warns about Rust source files that are not reachable from a Cargo target.
5
6use super::Lint;
7use super::LintCtx;
8use super::Lintable;
9use std::collections::HashSet;
10use std::path::Path;
11use std::path::PathBuf;
12use toml_edit::DocumentMut;
13
14#[derive(Default)]
15struct References {
16    file_names: HashSet<String>,
17    module_names: HashSet<String>,
18}
19
20pub struct OrphanedRustFiles {
21    files: Vec<PathBuf>,
22    references: References,
23}
24
25impl Lint for OrphanedRustFiles {
26    fn new(_ctx: &LintCtx) -> Self {
27        Self {
28            files: Vec::new(),
29            references: References::default(),
30        }
31    }
32
33    fn enter_workspace(&mut self, _content: &Lintable<DocumentMut>) {}
34
35    fn enter_crate(&mut self, _content: &Lintable<DocumentMut>) {
36        self.files.clear();
37        self.references.clear();
38    }
39
40    fn visit_file(&mut self, content: &mut Lintable<String>) {
41        self.files.push(content.path().to_owned());
42        self.references.extend(content);
43    }
44
45    fn exit_crate(&mut self, content: &mut Lintable<DocumentMut>) {
46        let crate_dir = content.path().parent().unwrap_or(Path::new(""));
47        let manifest = content.raw().unwrap_or_default();
48        self.references.extend(manifest);
49
50        for file in &self.files {
51            let relative_path = file.strip_prefix(crate_dir).unwrap();
52            if is_cargo_target(relative_path) || self.references.contains(relative_path) {
53                continue;
54            }
55
56            log::warn!(
57                "{}: Rust source file is not referenced by a Cargo target, module, or include",
58                file.display(),
59            );
60        }
61    }
62
63    fn exit_workspace(&mut self, _content: &mut Lintable<DocumentMut>) {}
64}
65
66fn is_cargo_target(path: &Path) -> bool {
67    let components: Vec<_> = path.components().collect();
68    match components.as_slice() {
69        [file] if file.as_os_str() == "build.rs" => true,
70        [src, file]
71            if src.as_os_str() == "src"
72                && matches!(file.as_os_str().to_str(), Some("lib.rs" | "main.rs")) =>
73        {
74            true
75        }
76        [directory, file]
77            if matches!(
78                directory.as_os_str().to_str(),
79                Some("examples" | "tests" | "benches")
80            ) && file.as_os_str().to_string_lossy().ends_with(".rs") =>
81        {
82            true
83        }
84        [src, bin, file]
85            if src.as_os_str() == "src"
86                && bin.as_os_str() == "bin"
87                && file.as_os_str().to_string_lossy().ends_with(".rs") =>
88        {
89            true
90        }
91        [directory, _, main]
92            if matches!(
93                directory.as_os_str().to_str(),
94                Some("examples" | "tests" | "benches")
95            ) && main.as_os_str() == "main.rs" =>
96        {
97            true
98        }
99        [src, bin, _, main]
100            if src.as_os_str() == "src"
101                && bin.as_os_str() == "bin"
102                && main.as_os_str() == "main.rs" =>
103        {
104            true
105        }
106        _ => false,
107    }
108}
109
110impl References {
111    fn clear(&mut self) {
112        self.file_names.clear();
113        self.module_names.clear();
114    }
115
116    fn extend(&mut self, content: &str) {
117        let mut remaining = content;
118        while let Some(index) = remaining.find(".rs") {
119            let end = index + ".rs".len();
120            let start = remaining[..index]
121                .char_indices()
122                .rfind(|(_, character)| !is_file_name_character(*character))
123                .map_or(0, |(index, character)| index + character.len_utf8());
124            self.file_names.insert(remaining[start..end].to_owned());
125            remaining = &remaining[end..];
126        }
127
128        for line in content.lines() {
129            let mut remaining = line;
130            while let Some(index) = remaining.find("mod ") {
131                remaining = &remaining[index + "mod ".len()..];
132                let Some(end) = remaining.find(';') else {
133                    break;
134                };
135                let module_name = &remaining[..end];
136                if !module_name.is_empty()
137                    && !module_name.chars().any(char::is_whitespace)
138                    && module_name
139                        .chars()
140                        .all(|character| is_file_name_character(character) || character == '#')
141                {
142                    self.module_names.insert(
143                        module_name
144                            .strip_prefix("r#")
145                            .unwrap_or(module_name)
146                            .to_owned(),
147                    );
148                }
149                remaining = &remaining[end + 1..];
150            }
151        }
152    }
153
154    fn contains(&self, path: &Path) -> bool {
155        let file_name = path.file_name().unwrap().to_string_lossy();
156        if self.file_names.contains(file_name.as_ref()) {
157            return true;
158        }
159
160        self.module_names.contains(module_name(path).as_ref())
161    }
162}
163
164fn is_file_name_character(character: char) -> bool {
165    character.is_alphanumeric() || matches!(character, '_' | '-' | '.')
166}
167
168fn module_name(path: &Path) -> std::borrow::Cow<'_, str> {
169    let file_name = path.file_name().unwrap().to_string_lossy();
170    if file_name == "mod.rs" {
171        let Some(module_name) = path.parent().and_then(Path::file_name) else {
172            return "".into();
173        };
174        module_name.to_string_lossy()
175    } else {
176        path.file_stem().unwrap().to_string_lossy()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn recognizes_cargo_targets() {
186        for path in [
187            "build.rs",
188            "src/lib.rs",
189            "src/main.rs",
190            "src/bin/tool.rs",
191            "src/bin/tool/main.rs",
192            "examples/demo.rs",
193            "examples/demo/main.rs",
194            "tests/integration.rs",
195            "benches/benchmark.rs",
196        ] {
197            assert!(is_cargo_target(Path::new(path)), "{path}");
198        }
199
200        assert!(!is_cargo_target(Path::new("src/device.rs")));
201        assert!(!is_cargo_target(Path::new("tests/common/mod.rs")));
202    }
203
204    #[test]
205    fn recognizes_module_and_path_references() {
206        let mut references = References::default();
207        references.extend(
208            r#"
209            //! Documentation about `mod service`.
210            pub(crate) mod device;
211            pub mod r#type;
212            include_str!("./templates/device.template.rs");
213            "#,
214        );
215
216        assert!(references.contains(Path::new("src/device.rs")));
217        assert!(references.contains(Path::new("src/device/mod.rs")));
218        assert!(references.contains(Path::new("src/type.rs")));
219        assert!(references.contains(Path::new("src/templates/device.template.rs")));
220        assert!(!references.contains(Path::new("src/other.rs")));
221    }
222
223    #[test]
224    fn crate_root_mod_rs_is_unreferenced() {
225        let references = References::default();
226        assert!(!references.contains(Path::new("mod.rs")));
227    }
228}