1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PathMapping {
7 pub root: PathBuf,
9 pub placeholder: String,
11}
12
13impl PathMapping {
14 pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
16 Self {
17 root: root.into(),
18 placeholder: placeholder.into(),
19 }
20 }
21
22 pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
24 let mut ordered = mappings.to_vec();
25 ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
26 ordered
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PathNormalizationError {
33 UnmappedAbsolutePath(PathBuf),
35 NonUtf8Path(PathBuf),
37}
38
39impl fmt::Display for PathNormalizationError {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::UnmappedAbsolutePath(path) => {
43 write!(
44 formatter,
45 "absolute path has no stable cache mapping: {}",
46 path.display()
47 )
48 }
49 Self::NonUtf8Path(path) => {
50 write!(
51 formatter,
52 "cache key paths must be valid UTF-8: {}",
53 path.display()
54 )
55 }
56 }
57 }
58}
59
60impl std::error::Error for PathNormalizationError {}
61
62pub fn normalize_mapped_path(
67 path: &Path,
68 working_dir: &Path,
69 mappings: &[PathMapping],
70) -> Result<String, PathNormalizationError> {
71 let mappings = resolve_path_mappings(mappings);
72 normalize_resolved_mapped_path(path, working_dir, &mappings)
73}
74
75pub fn resolve_path_mappings(mappings: &[PathMapping]) -> Vec<PathMapping> {
80 mappings
81 .iter()
82 .map(|mapping| PathMapping {
83 root: resolve_mapping_root(&mapping.root),
84 placeholder: mapping.placeholder.clone(),
85 })
86 .collect()
87}
88
89pub fn normalize_resolved_mapped_path(
92 path: &Path,
93 working_dir: &Path,
94 mappings: &[PathMapping],
95) -> Result<String, PathNormalizationError> {
96 normalize_resolved_mapped_path_with(&PathAliases::default(), path, working_dir, mappings)
97}
98
99#[derive(Debug, Default)]
109pub struct PathAliases {
110 #[cfg(unix)]
111 directories:
112 std::cell::RefCell<std::collections::HashMap<PathBuf, std::rc::Rc<ResolvedDirectory>>>,
113}
114
115#[cfg(unix)]
119const MAX_DIRECTORY_ENTRIES: usize = 256;
120
121#[cfg(unix)]
124#[derive(Debug)]
125struct ResolvedDirectory {
126 path: PathBuf,
127 entries: std::collections::HashSet<std::ffi::OsString>,
128}
129
130pub fn normalize_resolved_mapped_path_with(
133 aliases: &PathAliases,
134 path: &Path,
135 working_dir: &Path,
136 mappings: &[PathMapping],
137) -> Result<String, PathNormalizationError> {
138 let absolute = if path.is_absolute() {
139 normalize_components(path)
140 } else {
141 normalize_components(&working_dir.join(path))
142 };
143 let resolved = if absolute.is_absolute() {
144 resolve_path_aliases_with(aliases, &absolute)
145 } else {
146 absolute.clone()
147 };
148 for mapping in mappings {
149 if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
150 let suffix = slash_path(relative)?;
151 return Ok(if suffix.is_empty() {
152 format!("${{{}}}", mapping.placeholder)
153 } else {
154 format!("${{{}}}/{suffix}", mapping.placeholder)
155 });
156 }
157 }
158 Err(PathNormalizationError::UnmappedAbsolutePath(absolute))
159}
160
161#[cfg(any(unix, windows))]
162fn resolve_path_aliases(path: &Path) -> PathBuf {
163 resolve_path_aliases_with(&PathAliases::default(), path)
164}
165
166#[cfg(any(unix, windows))]
167fn resolve_path_aliases_with(aliases: &PathAliases, path: &Path) -> PathBuf {
168 let mut existing = path;
169 let mut missing = Vec::new();
170 loop {
171 match aliases.canonicalize(existing) {
172 Ok(mut resolved) => {
173 for component in missing.iter().rev() {
174 resolved.push(component);
175 }
176 return normalize_components(&resolved);
177 }
178 Err(_) => {
179 let Some(name) = existing.file_name() else {
180 return path.to_path_buf();
181 };
182 missing.push(name.to_os_string());
183 let Some(parent) = existing.parent() else {
184 return path.to_path_buf();
185 };
186 existing = parent;
187 }
188 }
189 }
190}
191
192#[cfg(not(any(unix, windows)))]
193fn resolve_path_aliases(path: &Path) -> PathBuf {
194 path.to_path_buf()
195}
196
197#[cfg(not(any(unix, windows)))]
198fn resolve_path_aliases_with(_aliases: &PathAliases, path: &Path) -> PathBuf {
199 path.to_path_buf()
200}
201
202impl PathAliases {
203 #[cfg(unix)]
205 fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
206 let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
207 return std::fs::canonicalize(path);
208 };
209 if parent.as_os_str().is_empty() {
210 return std::fs::canonicalize(path);
211 }
212 let known = self.directories.borrow().get(parent).cloned();
214 let resolved_parent = match known {
215 Some(resolved) => resolved,
216 None => {
217 let resolved = std::fs::canonicalize(parent)?;
221 let entries = std::fs::read_dir(&resolved)
224 .map(|listing| {
225 listing
226 .take(MAX_DIRECTORY_ENTRIES)
227 .filter_map(Result::ok)
228 .map(|entry| entry.file_name())
229 .collect()
230 })
231 .unwrap_or_default();
232 let resolved = std::rc::Rc::new(ResolvedDirectory {
233 path: resolved,
234 entries,
235 });
236 self.directories
237 .borrow_mut()
238 .insert(parent.to_path_buf(), resolved.clone());
239 resolved
240 }
241 };
242 let candidate = resolved_parent.path.join(name);
243 if resolved_parent.entries.contains(name)
249 && !std::fs::symlink_metadata(&candidate)?
250 .file_type()
251 .is_symlink()
252 {
253 Ok(candidate)
254 } else {
255 std::fs::canonicalize(&candidate)
256 }
257 }
258
259 #[cfg(windows)]
262 fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
263 std::fs::canonicalize(path)
264 }
265}
266
267fn resolve_mapping_root(root: &Path) -> PathBuf {
268 let root = normalize_components(root);
269 if root.is_absolute() {
270 resolve_path_aliases(&root)
271 } else {
272 root
273 }
274}
275
276fn normalize_components(path: &Path) -> PathBuf {
277 let mut normalized = PathBuf::new();
278 for component in path.components() {
279 match component {
280 Component::CurDir => {}
281 Component::ParentDir => {
282 normalized.pop();
283 }
284 component => normalized.push(component.as_os_str()),
285 }
286 }
287 normalized
288}
289
290fn slash_path(path: &Path) -> Result<String, PathNormalizationError> {
291 path.components()
292 .filter_map(|component| match component {
293 Component::Normal(value) => Some(
294 value
295 .to_str()
296 .map(ToOwned::to_owned)
297 .ok_or_else(|| PathNormalizationError::NonUtf8Path(path.to_path_buf())),
298 ),
299 _ => None,
300 })
301 .collect::<Result<Vec<_>, _>>()
302 .map(|components| components.join("/"))
303}
304
305#[cfg(all(test, windows))]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn maps_verbatim_windows_paths_to_non_verbatim_roots() {
311 let directory = tempfile::tempdir().unwrap();
312 let input = directory.path().join("clippy.toml");
313 std::fs::write(&input, "disallowed-methods = []").unwrap();
314 let verbatim = std::fs::canonicalize(&input).unwrap();
315 assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
316
317 let normalized = normalize_mapped_path(
318 &verbatim,
319 directory.path(),
320 &[PathMapping::new(directory.path(), "workspace")],
321 )
322 .unwrap();
323
324 assert_eq!(normalized, "${workspace}/clippy.toml");
325 }
326}
327
328#[cfg(all(test, unix))]
329mod unix_tests {
330 use super::*;
331
332 #[test]
333 fn resolves_aliases_through_remembered_parents() {
334 let directory = tempfile::tempdir().unwrap();
335 let aliases = PathAliases::default();
336 let root = std::fs::canonicalize(directory.path()).unwrap();
337 let physical = root.join("physical");
338 std::fs::create_dir_all(physical.join("src")).unwrap();
339 std::fs::write(physical.join("src").join("lib.rs"), "").unwrap();
340 std::fs::write(physical.join("real.rs"), "").unwrap();
341 std::os::unix::fs::symlink(&physical, root.join("alias")).unwrap();
342 std::os::unix::fs::symlink(physical.join("real.rs"), physical.join("link.rs")).unwrap();
343 let mappings = [PathMapping::new(&physical, "workspace")];
344
345 let through_alias = root.join("alias").join("src").join("lib.rs");
347 assert_eq!(
348 normalize_resolved_mapped_path_with(&aliases, &through_alias, &root, &mappings)
349 .unwrap(),
350 "${workspace}/src/lib.rs"
351 );
352 let sibling = root.join("alias").join("src").join("missing.rs");
353 assert_eq!(
354 normalize_resolved_mapped_path_with(&aliases, &sibling, &root, &mappings).unwrap(),
355 "${workspace}/src/missing.rs"
356 );
357 assert_eq!(
359 normalize_resolved_mapped_path_with(
360 &aliases,
361 &physical.join("link.rs"),
362 &root,
363 &mappings
364 )
365 .unwrap(),
366 "${workspace}/real.rs"
367 );
368 let unborn = root
370 .join("alias")
371 .join("target")
372 .join("deps")
373 .join("out.rlib");
374 assert_eq!(
375 normalize_resolved_mapped_path_with(&aliases, &unborn, &root, &mappings).unwrap(),
376 "${workspace}/target/deps/out.rlib"
377 );
378 std::fs::write(physical.join("src").join("late.rs"), "").unwrap();
381 assert_eq!(
382 normalize_resolved_mapped_path_with(
383 &aliases,
384 &root.join("alias").join("src").join("late.rs"),
385 &root,
386 &mappings
387 )
388 .unwrap(),
389 "${workspace}/src/late.rs"
390 );
391 }
392
393 #[test]
394 fn large_directory_listings_are_bounded_without_losing_paths() {
395 let directory = tempfile::tempdir().unwrap();
396 let root = std::fs::canonicalize(directory.path()).unwrap();
397 let store = root.join("store");
398 std::fs::create_dir(&store).unwrap();
399 for index in 0..MAX_DIRECTORY_ENTRIES * 2 {
400 std::fs::write(store.join(format!("header-{index}.h")), "").unwrap();
401 }
402 std::os::unix::fs::symlink(store.join("header-0.h"), store.join("link.h")).unwrap();
403 let aliases = PathAliases::default();
404 let mappings = [PathMapping::new(&store, "store")];
405 for index in 0..MAX_DIRECTORY_ENTRIES * 2 {
406 let name = format!("header-{index}.h");
407 assert_eq!(
408 normalize_resolved_mapped_path_with(&aliases, &store.join(&name), &root, &mappings)
409 .unwrap(),
410 format!("${{store}}/{name}")
411 );
412 }
413 assert_eq!(aliases.directories.borrow().len(), 1);
415 assert_eq!(
416 aliases.directories.borrow()[&store].entries.len(),
417 MAX_DIRECTORY_ENTRIES
418 );
419 assert_eq!(
420 normalize_resolved_mapped_path_with(&aliases, &store.join("link.h"), &root, &mappings)
421 .unwrap(),
422 "${store}/header-0.h"
423 );
424 assert_eq!(
425 normalize_resolved_mapped_path_with(
426 &aliases,
427 &store.join("missing/subdir/header.h"),
428 &root,
429 &mappings
430 )
431 .unwrap(),
432 "${store}/missing/subdir/header.h"
433 );
434 }
435
436 #[test]
437 fn a_retargeted_alias_is_seen_by_the_next_caller() {
438 let directory = tempfile::tempdir().unwrap();
439 let root = std::fs::canonicalize(directory.path()).unwrap();
440 for name in ["one", "two"] {
441 std::fs::create_dir_all(root.join(name).join("src")).unwrap();
442 std::fs::write(root.join(name).join("src").join("lib.rs"), "").unwrap();
443 }
444 let alias = root.join("alias");
445 std::os::unix::fs::symlink(root.join("one"), &alias).unwrap();
446 let mappings = [
447 PathMapping::new(root.join("one"), "one"),
448 PathMapping::new(root.join("two"), "two"),
449 ];
450 let input = alias.join("src").join("lib.rs");
451
452 let first = PathAliases::default();
453 assert_eq!(
454 normalize_resolved_mapped_path_with(&first, &input, &root, &mappings).unwrap(),
455 "${one}/src/lib.rs"
456 );
457 std::fs::remove_file(&alias).unwrap();
458 std::os::unix::fs::symlink(root.join("two"), &alias).unwrap();
459 assert_eq!(
462 normalize_resolved_mapped_path_with(&PathAliases::default(), &input, &root, &mappings)
463 .unwrap(),
464 "${two}/src/lib.rs"
465 );
466 }
467}