xtask/tasks/fmt/lints/
orphaned_rs.rs1use 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}