1use 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 #[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
29pub struct PackageEntryResolver<'a> {
38 graph: &'a ModuleGraph,
39 package_root: &'a Path,
40 canonical_modules: OnceLock<FxHashMap<PathBuf, FileId>>,
41}
42
43impl PackageEntryResolver<'_> {
44 #[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
73fn 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
89fn 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 #[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 assert_eq!(resolver.file_id(&bin, lookup), None);
172 assert_eq!(resolver.file_id(&real_file, lookup), Some(FileId(0)));
173
174 std::fs::remove_file(&link_dir).unwrap();
177 assert_eq!(resolver.file_id(&real_file, lookup), Some(FileId(0)));
178
179 let fresh = graph.package_entry_resolver(dir.path());
181 assert_eq!(fresh.file_id(&real_file, lookup), None);
182 }
183
184 #[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 #[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 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}