Skip to main content

fallow_graph/graph/
package_entry.rs

1//! Find the graph module of a package entry path.
2//!
3//! The public API entry sets of `fallow-core` and `fallow-engine` both use
4//! this lookup, so both crates match a package entry to the same module.
5
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9use fallow_types::discover::FileId;
10use rustc_hash::FxHashMap;
11
12use super::ModuleGraph;
13
14impl ModuleGraph {
15    /// Return a resolver for the package entry paths of `package_root`.
16    #[must_use]
17    pub fn package_entry_resolver<'a>(
18        &'a self,
19        package_root: &'a Path,
20    ) -> PackageEntryResolver<'a> {
21        PackageEntryResolver {
22            graph: self,
23            package_root,
24            canonical_modules: OnceLock::new(),
25        }
26    }
27}
28
29/// Find the graph module of a package entry path for one package root.
30///
31/// The resolver tries the path as given, then its canonical form. Then it
32/// compares the canonical form with the canonical path of each module under
33/// the package root. That last step finds a module that the walk reached
34/// through a symlink. The resolver builds the canonical module index on the
35/// first miss and keeps it, so a package with many missed entry candidates
36/// resolves each module path one time only.
37pub struct PackageEntryResolver<'a> {
38    graph: &'a ModuleGraph,
39    package_root: &'a Path,
40    canonical_modules: OnceLock<FxHashMap<PathBuf, FileId>>,
41}
42
43impl PackageEntryResolver<'_> {
44    /// Return the module of the package entry `entry_path`.
45    ///
46    /// `lookup` maps a module path to its `FileId`.
47    #[must_use]
48    pub fn file_id(
49        &self,
50        entry_path: &Path,
51        lookup: impl Fn(&Path) -> Option<FileId>,
52    ) -> Option<FileId> {
53        lookup(entry_path).or_else(|| {
54            let canonical = dunce::canonicalize(entry_path).ok()?;
55            lookup(&canonical).or_else(|| {
56                self.canonical_modules
57                    .get_or_init(|| {
58                        canonical_module_index(
59                            self.graph
60                                .modules
61                                .iter()
62                                .map(|module| (module.path.as_path(), module.file_id)),
63                            self.package_root,
64                        )
65                    })
66                    .get(&canonical)
67                    .copied()
68            })
69        })
70    }
71}
72
73/// Map the canonical path of each candidate under `package_root` to its
74/// `FileId`. The first candidate wins when two paths share a canonical form.
75fn canonical_module_index<'a>(
76    candidates: impl Iterator<Item = (&'a Path, FileId)>,
77    package_root: &Path,
78) -> FxHashMap<PathBuf, FileId> {
79    let mut canonical_dirs = FxHashMap::default();
80    let mut index = FxHashMap::default();
81    for (path, file_id) in candidates.filter(|(path, _)| path.starts_with(package_root)) {
82        if let Some(canonical) = canonical_module_path(path, &mut canonical_dirs) {
83            index.entry(canonical).or_insert(file_id);
84        }
85    }
86    index
87}
88
89/// Return the same path as `dunce::canonicalize(path)`, with one directory
90/// resolution for each distinct parent directory.
91///
92/// When the file itself is not a symlink, its canonical path is the canonical
93/// parent directory joined with the file name. A symlinked file gets a full
94/// resolution.
95fn canonical_module_path(
96    path: &Path,
97    canonical_dirs: &mut FxHashMap<PathBuf, Option<PathBuf>>,
98) -> Option<PathBuf> {
99    let metadata = std::fs::symlink_metadata(path).ok()?;
100    let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
101        return dunce::canonicalize(path).ok();
102    };
103    if metadata.file_type().is_symlink() || parent.as_os_str().is_empty() {
104        return dunce::canonicalize(path).ok();
105    }
106    canonical_dirs
107        .entry(parent.to_path_buf())
108        .or_insert_with(|| dunce::canonicalize(parent).ok())
109        .as_ref()
110        .map(|dir| dir.join(name))
111}
112
113#[cfg(all(test, unix))]
114mod tests {
115    use super::*;
116    use crate::resolve::ResolvedModule;
117    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
118
119    fn graph_of(paths: &[PathBuf]) -> ModuleGraph {
120        let files: Vec<DiscoveredFile> = paths
121            .iter()
122            .zip(0..)
123            .map(|(path, id)| DiscoveredFile {
124                id: FileId(id),
125                path: path.clone(),
126                size_bytes: 1,
127            })
128            .collect();
129        let resolved: Vec<ResolvedModule> = files
130            .iter()
131            .map(|file| ResolvedModule {
132                file_id: file.id,
133                path: file.path.clone(),
134                ..Default::default()
135            })
136            .collect();
137        let entry_points = vec![EntryPoint {
138            path: paths[0].clone(),
139            source: EntryPointSource::PackageJsonMain,
140        }];
141        ModuleGraph::build(&resolved, &entry_points, &files)
142    }
143
144    // Entry resolution probes many candidates for one package. Each miss must
145    // reuse the canonical index of the first miss. A resolver that scans the
146    // modules again on each miss resolves the real path of every module per
147    // probe, which costs seconds on a large project.
148    #[cfg_attr(miri, ignore)]
149    #[test]
150    fn resolver_builds_the_canonical_index_once_per_package() {
151        let dir = tempfile::tempdir().unwrap();
152        let real_dir = dir.path().join("real");
153        std::fs::create_dir(&real_dir).unwrap();
154        let real_file = real_dir.join("mod.ts");
155        std::fs::write(&real_file, "").unwrap();
156        let link_dir = dir.path().join("link");
157        std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
158        let bin = dir.path().join("bin");
159        std::fs::write(&bin, "").unwrap();
160
161        let graph = graph_of(&[link_dir.join("mod.ts")]);
162        let by_path: FxHashMap<PathBuf, FileId> = graph
163            .modules
164            .iter()
165            .map(|module| (module.path.clone(), module.file_id))
166            .collect();
167        let lookup = |path: &Path| by_path.get(path).copied();
168        let resolver = graph.package_entry_resolver(dir.path());
169
170        // An entry on disk that is not in the graph builds the index.
171        assert_eq!(resolver.file_id(&bin, lookup), None);
172        assert_eq!(resolver.file_id(&real_file, lookup), Some(FileId(0)));
173
174        // Without the symlink, a new scan cannot reach the module. The kept
175        // index still can.
176        std::fs::remove_file(&link_dir).unwrap();
177        assert_eq!(resolver.file_id(&real_file, lookup), Some(FileId(0)));
178
179        // A new resolver scans again and misses.
180        let fresh = graph.package_entry_resolver(dir.path());
181        assert_eq!(fresh.file_id(&real_file, lookup), None);
182    }
183
184    // A module whose discovered (raw) path goes through a symlinked directory
185    // has a raw path that differs from the canonical entry path. The raw-map
186    // lookup cannot reach it, so the package-scoped canonical index must.
187    #[cfg_attr(miri, ignore)]
188    #[test]
189    fn scoped_canonical_index_matches_module_reached_through_symlink() {
190        let dir = tempfile::tempdir().unwrap();
191        let real_dir = dir.path().join("real");
192        std::fs::create_dir(&real_dir).unwrap();
193        let real_file = real_dir.join("mod.ts");
194        std::fs::write(&real_file, "export const x = 1;\n").unwrap();
195        let link_dir = dir.path().join("link");
196        std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
197
198        let module_raw_path = link_dir.join("mod.ts");
199        let canonical_entry = dunce::canonicalize(&real_file).unwrap();
200        let package_root = dir.path();
201
202        let candidates = [(module_raw_path.as_path(), FileId(7))];
203        let index = canonical_module_index(candidates.iter().copied(), package_root);
204        assert_eq!(index.get(&canonical_entry), Some(&FileId(7)));
205
206        let outside_root = dir.path().join("other-package");
207        let index = canonical_module_index(candidates.iter().copied(), &outside_root);
208        assert!(index.is_empty());
209
210        let unrelated = dunce::canonicalize(dir.path()).unwrap().join("nope.ts");
211        let index = canonical_module_index(candidates.iter().copied(), package_root);
212        assert_eq!(index.get(&unrelated), None);
213    }
214
215    // A module file that is itself a symlink resolves to its target, the same
216    // as a full canonicalization of the module path.
217    #[cfg_attr(miri, ignore)]
218    #[test]
219    fn canonical_index_resolves_symlinked_module_files() {
220        let dir = tempfile::tempdir().unwrap();
221        let target = dir.path().join("target.ts");
222        std::fs::write(&target, "export const x = 1;\n").unwrap();
223        let link = dir.path().join("link.ts");
224        std::os::unix::fs::symlink(&target, &link).unwrap();
225        let missing = dir.path().join("missing.ts");
226
227        let candidates = [
228            (link.as_path(), FileId(1)),
229            (target.as_path(), FileId(2)),
230            (missing.as_path(), FileId(3)),
231        ];
232        let index = canonical_module_index(candidates.iter().copied(), dir.path());
233
234        // Both paths share one canonical form, and the first candidate wins.
235        assert_eq!(index.len(), 1);
236        assert_eq!(
237            index.get(&dunce::canonicalize(&target).unwrap()),
238            Some(&FileId(1))
239        );
240    }
241
242    #[cfg_attr(miri, ignore)]
243    #[test]
244    fn canonical_module_path_matches_full_canonicalization() {
245        let dir = tempfile::tempdir().unwrap();
246        let nested = dir.path().join("a/b");
247        std::fs::create_dir_all(&nested).unwrap();
248        let file = nested.join("mod.ts");
249        std::fs::write(&file, "").unwrap();
250        let link_dir = dir.path().join("via");
251        std::os::unix::fs::symlink(&nested, &link_dir).unwrap();
252
253        let mut canonical_dirs = FxHashMap::default();
254        for path in [
255            file,
256            link_dir.join("mod.ts"),
257            dir.path().join("a/b/../b/mod.ts"),
258        ] {
259            assert_eq!(
260                canonical_module_path(&path, &mut canonical_dirs),
261                dunce::canonicalize(&path).ok(),
262                "{}",
263                path.display()
264            );
265        }
266        assert_eq!(
267            canonical_module_path(&dir.path().join("a/none.ts"), &mut canonical_dirs),
268            None
269        );
270    }
271}