1use std::collections::BTreeSet;
22use std::path::{Path, PathBuf};
23
24use anyhow::{anyhow, Context, Result};
25use syn::spanned::Spanned;
26use syn::visit::{self, Visit};
27
28pub use crate::violation::Violation;
29
30const RULE: &str = "no-out-of-module-call";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
36pub enum Language {
37 #[value(name = "rust")]
39 Rust,
40}
41
42pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
49 let root = root.as_ref();
50 let deps = external_deps(root)?;
51
52 let mut files = Vec::new();
53 collect_rust_files(root, &mut files)?;
54 files.sort();
55
56 let mut violations = Vec::new();
57 for file in &files {
58 let source = std::fs::read_to_string(file)
59 .with_context(|| format!("reading source file `{}`", file.display()))?;
60 let ast = syn::parse_file(&source)
61 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
62 let mut visitor = IsolationVisitor {
63 file,
64 deps: &deps,
65 test_depth: 0,
66 violations: Vec::new(),
67 };
68 visitor.visit_file(&ast);
69 violations.append(&mut visitor.violations);
70 }
71
72 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
73 Ok(violations)
74}
75
76struct IsolationVisitor<'a> {
80 file: &'a Path,
81 deps: &'a BTreeSet<String>,
82 test_depth: usize,
83 violations: Vec<Violation>,
84}
85
86impl<'ast> Visit<'ast> for IsolationVisitor<'_> {
87 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
88 let is_test = has_cfg_test(&node.attrs);
89 if is_test {
90 self.test_depth += 1;
91 }
92 visit::visit_item_mod(self, node);
93 if is_test {
94 self.test_depth -= 1;
95 }
96 }
97
98 fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
99 if self.test_depth > 0 {
100 if let syn::Expr::Path(path_expr) = node.func.as_ref() {
101 if let Some(kind) = classify(&path_expr.path, self.deps) {
102 self.violations.push(Violation {
103 file: self.file.to_path_buf(),
104 line: node.span().start().line,
105 rule: RULE,
106 message: format!(
107 "unit test calls `{}` out of its own module ({kind}); \
108 inject a trait double — only `super::` is in-module",
109 render_path(&path_expr.path),
110 ),
111 });
112 }
113 }
114 }
115 visit::visit_expr_call(self, node);
116 }
117}
118
119fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
123 let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
124 match segs.first().map(String::as_str)? {
125 "self" | "Self" => None,
127 "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
128 "crate" => Some("first-party module"),
129 "std" => is_effectful_std(&segs).then_some("effectful std"),
130 "core" | "alloc" => None,
132 other => deps.contains(other).then_some("external crate"),
135 }
136}
137
138fn is_effectful_std(segs: &[String]) -> bool {
144 match segs.get(1).map(String::as_str) {
145 Some("fs" | "net" | "process" | "env" | "thread" | "os") => true,
146 Some("io") => matches!(
147 segs.get(2).map(String::as_str),
148 Some("stdin" | "stdout" | "stderr")
149 ),
150 Some("time") => {
151 matches!(
152 segs.get(2).map(String::as_str),
153 Some("SystemTime" | "Instant")
154 ) && segs.get(3).map(String::as_str) == Some("now")
155 }
156 _ => false,
157 }
158}
159
160fn render_path(path: &syn::Path) -> String {
163 let mut out = String::new();
164 if path.leading_colon.is_some() {
165 out.push_str("::");
166 }
167 for (i, seg) in path.segments.iter().enumerate() {
168 if i > 0 {
169 out.push_str("::");
170 }
171 out.push_str(&seg.ident.to_string());
172 }
173 out
174}
175
176fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
179 attrs.iter().any(|attr| {
180 attr.path().is_ident("cfg")
181 && attr
182 .meta
183 .require_list()
184 .map(|list| cfg_mentions_test(list.tokens.clone()))
185 .unwrap_or(false)
186 })
187}
188
189fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
193 tokens.into_iter().any(|tt| match tt {
194 proc_macro2::TokenTree::Ident(id) => id == "test",
195 proc_macro2::TokenTree::Group(group) => cfg_mentions_test(group.stream()),
196 _ => false,
197 })
198}
199
200fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
206 let manifest = root.join("Cargo.toml");
207 if !manifest.is_file() {
208 return Ok(BTreeSet::new());
209 }
210 let text = std::fs::read_to_string(&manifest)
211 .with_context(|| format!("reading `{}`", manifest.display()))?;
212 let value: toml::Value =
213 toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
214 let mut deps = BTreeSet::new();
215 if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
216 for name in table.keys() {
217 deps.insert(name.replace('-', "_"));
218 }
219 }
220 Ok(deps)
221}
222
223fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
225 let entries =
226 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
227 for entry in entries {
228 let path = entry
229 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
230 .path();
231 if path.is_dir() {
232 collect_rust_files(&path, out)?;
233 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
234 out.push(path);
235 }
236 }
237 Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
246 let ast = syn::parse_file(src).expect("snippet parses");
247 let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
248 let mut visitor = IsolationVisitor {
249 file: Path::new("snippet.rs"),
250 deps: &dep_set,
251 test_depth: 0,
252 violations: Vec::new(),
253 };
254 visitor.visit_file(&ast);
255 visitor.violations
256 }
257
258 #[test]
259 fn flags_each_out_of_module_form() {
260 let src = "\
261#[cfg(test)]
262mod tests {
263 use super::*;
264 #[test]
265 fn t() {
266 let _ = crate::store::load();
267 let _ = std::fs::read(\"x\");
268 let _ = rand::random::<u8>();
269 let _ = super::super::util::help();
270 }
271}
272";
273 let violations = violations_in(src, &["rand"]);
274 assert_eq!(violations.len(), 4, "got {violations:?}");
275 assert!(violations.iter().all(|v| v.rule == RULE));
276 }
277
278 #[test]
279 fn allows_in_module_calls() {
280 let src = "\
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use std::io::Cursor;
285 #[test]
286 fn t() {
287 let _ = super::widget();
288 let _ = self::helper();
289 let _ = Cursor::new(b\"x\");
290 let _ = std::collections::HashMap::<u8, u8>::new();
291 assert_eq!(1, 1);
292 }
293}
294";
295 assert!(violations_in(src, &["rand"]).is_empty());
296 }
297
298 #[test]
299 fn ignores_calls_outside_test_modules() {
300 let src = "fn run() { let _ = crate::other::go(); }";
301 assert!(violations_in(src, &[]).is_empty());
302 }
303
304 #[test]
305 fn reports_the_call_line() {
306 let src = "\
308#[cfg(test)]
309mod tests {
310 fn t() {
311 let _ = crate::other::go();
312 }
313}
314";
315 let violations = violations_in(src, &[]);
316 assert_eq!(violations.len(), 1);
317 assert_eq!(violations[0].line, 4);
318 }
319
320 #[test]
321 fn effectful_std_policy() {
322 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
323 assert!(is_effectful_std(&segs("std::fs::read")));
325 assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
326 assert!(is_effectful_std(&segs("std::env::var")));
327 assert!(is_effectful_std(&segs("std::process::exit")));
328 assert!(is_effectful_std(&segs("std::thread::sleep")));
329 assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
330 assert!(is_effectful_std(&segs("std::io::stdout")));
331 assert!(!is_effectful_std(&segs("std::collections::HashMap")));
333 assert!(!is_effectful_std(&segs("std::io::Cursor")));
334 assert!(!is_effectful_std(&segs("std::time::Duration")));
335 assert!(!is_effectful_std(&segs("std::cmp::min")));
336 }
337
338 #[test]
339 fn classify_leading_segment() {
340 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
341 let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
342 assert_eq!(classify(&path("super::foo"), &deps), None);
343 assert_eq!(classify(&path("self::foo"), &deps), None);
344 assert_eq!(classify(&path("Local::new"), &deps), None);
345 assert_eq!(
346 classify(&path("super::super::foo"), &deps),
347 Some("ancestor module")
348 );
349 assert_eq!(
350 classify(&path("crate::a::b"), &deps),
351 Some("first-party module")
352 );
353 assert_eq!(
354 classify(&path("rand::random"), &deps),
355 Some("external crate")
356 );
357 assert_eq!(
358 classify(&path("std::fs::read"), &deps),
359 Some("effectful std")
360 );
361 assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
362 }
363
364 #[test]
365 fn recognizes_cfg_test_attribute() {
366 let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
367 assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
368 assert!(has_cfg_test(
369 &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
370 ));
371 assert!(!has_cfg_test(
372 &module("#[cfg(feature = \"test\")] mod t {}").attrs
373 ));
374 assert!(!has_cfg_test(&module("mod t {}").attrs));
375 }
376}