diff --git a/README.md b/README.md index 282bf20..e74c87e 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,9 @@ How a change is followed: `Drop::drop`, `From::from`) is called by formatting, operators and conversions that name nothing. A change to one reaches every definition that uses the type's file. + - A path is followed through re-exports: with `pub use parts::*` in the + crate root, `crate::ports::Runner` is the `Runner` of `parts::ports`. + A trait reached that way is one the workspace declares. - A `macro_rules!` macro is found by its name wherever it is used. - A test that runs the package's binary (`CARGO_BIN_EXE_*`, `assert_cmd`) runs for any change to the package's sources. diff --git a/src/related/resolve.rs b/src/related/resolve.rs index 82a05df..a2a3ca4 100644 --- a/src/related/resolve.rs +++ b/src/related/resolve.rs @@ -46,8 +46,13 @@ pub struct RustIndex { files: HashMap)>>, /// Library targets by crate name. libs: HashMap, + /// What each file re-exports: the path, and whether all of it (`::*`). + reexports: HashMap>, } +/// How many re-exports a path is followed through. +const MAX_REEXPORTS: usize = 8; + impl RustIndex { pub fn build<'p>(root: &Path, sources: impl Iterator) -> Self { let mut index = RustIndex::default(); @@ -102,15 +107,29 @@ impl RustIndex { /// Follow `path` (`crate::a::b::Name`) from `from` as far as modules /// go: the file of the innermost module, and the segments left over, - /// which name something inside it. A path that leads to no module of - /// the workspace stays in `from`, whole. + /// which name something inside it. A module may be one that a module on + /// the way re-exports (`pub use other::*`). A path that leads to no + /// module of the workspace stays in `from`, whole. pub fn follow(&self, from: &Path, path: &str) -> Vec<(PathBuf, Vec)> { let segments: Vec = path.split("::").map(str::to_string).collect(); + let mut found = self.modules_of(from, &segments, 0); + if found.is_empty() { + found.push((from.to_path_buf(), segments)); + } + found + } + + fn modules_of( + &self, + from: &Path, + segments: &[String], + depth: usize, + ) -> Vec<(PathBuf, Vec)> { let mut found: Vec<(PathBuf, Vec)> = Vec::new(); for (target, module) in self.owners(from) { let mut target = *target; let mut base = module.clone(); - let mut rest = &segments[..]; + let mut rest = segments; match rest.first().map(String::as_str) { Some("crate") => { base.clear(); @@ -151,16 +170,40 @@ impl RustIndex { } taken += 1; } - if let Some(file) = modules.get(&base) { - let entry = (file.clone(), rest[taken..].to_vec()); + let Some(file) = modules.get(&base) else { + continue; + }; + let left = &rest[taken..]; + // The next segment is no module of this one. It may be a module + // this one re-exports from elsewhere; if so, the path goes on + // there and names nothing here. + let mut through: Vec<(PathBuf, Vec)> = Vec::new(); + if left.len() > 1 && depth < MAX_REEXPORTS { + for (spec, all) in self.reexports.get(file).into_iter().flatten() { + let mut onward: Vec = spec.split("::").map(str::to_string).collect(); + match all { + true => onward.extend_from_slice(left), + false if onward.last() == left.first() => { + onward.extend_from_slice(&left[1..]); + } + false => continue, + } + for entry in self.modules_of(file, &onward, depth + 1) { + if entry.1.len() < left.len() && !through.contains(&entry) { + through.push(entry); + } + } + } + } + if through.is_empty() { + through.push((file.clone(), left.to_vec())); + } + for entry in through { if !found.contains(&entry) { found.push(entry); } } } - if found.is_empty() { - found.push((from.to_path_buf(), segments)); - } found } @@ -242,6 +285,14 @@ impl Resolvers { ), }; for (path, facts) in ws.sources { + if facts.family == Family::Rust && !facts.reexports.is_empty() { + let reexports = facts + .reexports + .iter() + .map(|spec| (spec.clone(), facts.uses_all.contains(spec))) + .collect(); + resolvers.rust.reexports.insert(path.clone(), reexports); + } match facts.family { Family::Elixir => { for module in &facts.modules { diff --git a/tests/related.rs b/tests/related.rs index 74befe2..d96e11a 100644 --- a/tests/related.rs +++ b/tests/related.rs @@ -914,6 +914,78 @@ fn rust_trait_methods_are_reached_through_the_trait() { ); } +// A crate that re-exports another crate's modules whole, so that its own +// code names them as `crate::…`. +const RUST_FACADE: &[(&str, &str)] = &[ + ( + "app/Cargo.toml", + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nparts = { path = \"crates/parts\" }\n", + ), + ( + "app/src/lib.rs", + "pub mod engine;\npub mod notes;\npub mod wiring;\npub use parts::*;\n", + ), + ( + "app/crates/parts/Cargo.toml", + "[package]\nname = \"parts\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ), + ("app/crates/parts/src/lib.rs", "pub mod ports;\n"), + ( + "app/crates/parts/src/ports.rs", + "pub trait Runner {\n fn run(&self) -> u32;\n}\n\npub trait Label {\n fn text(&self) -> String;\n}\n", + ), + ( + "app/src/engine.rs", + "use crate::ports::Runner;\n\npub struct Engine;\n\nimpl Runner for Engine {\n fn run(&self) -> u32 {\n 1\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn engine_runs() {\n let engine = Engine;\n assert_eq!(engine.run(), 1);\n }\n}\n", + ), + ( + "app/src/wiring.rs", + "use crate::ports::Runner;\n\npub fn drive(runner: &dyn Runner) -> u32 {\n runner.run()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::engine::Engine;\n\n #[test]\n fn drives_the_engine() {\n assert_eq!(drive(&Engine), 1);\n }\n}\n", + ), + // Uses the same re-exported module, and nothing of the engine. + ( + "app/src/notes.rs", + "use crate::ports::Label;\n\npub struct Note;\n\nimpl Label for Note {\n fn text(&self) -> String {\n String::from(\"note\")\n }\n}\n\npub fn show(label: &dyn Label) -> String {\n label.text()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn shows_a_note() {\n assert_eq!(show(&Note), \"note\");\n }\n}\n", + ), +]; + +#[test] +fn rust_paths_are_followed_through_a_glob_re_export_of_another_crate() { + let ws = Workspace::new(RUST_FACADE); + // `crate::ports` is `parts::ports`, brought in by `pub use parts::*`. + // `Runner` is therefore a trait of the workspace, reached by the code + // that calls `run` on one, not by everything that uses the crate. + ws.edit("app/src/engine.rs", " 1\n", " 1 + 0\n"); + assert_eq!( + ws.plan("test").commands("//app:test"), + [ + "cargo test --lib -- --exact engine::tests::engine_runs wiring::tests::drives_the_engine", + "cargo test --doc" + ] + ); + + // The same when the module is re-exported by name. + let named: Vec<(&str, &str)> = RUST_FACADE + .iter() + .map(|&(path, source)| match path { + "app/src/lib.rs" => ( + path, + "pub mod engine;\npub mod notes;\npub mod wiring;\npub use parts::ports;\n", + ), + _ => (path, source), + }) + .collect(); + let ws = Workspace::new(&named); + ws.edit("app/src/engine.rs", " 1\n", " 1 + 0\n"); + assert_eq!( + ws.plan("test").commands("//app:test"), + [ + "cargo test --lib -- --exact engine::tests::engine_runs wiring::tests::drives_the_engine", + "cargo test --doc" + ] + ); +} + #[test] fn rust_macros_are_found_by_name() { let ws = Workspace::new(RUST_KIT);