1use crate::{
2 Config, FigmentProviders, foundry_toml_dir_entries, remappings_from_env_var,
3 remappings_from_newline,
4};
5use figment::{
6 Error, Figment, Metadata, Profile, Provider,
7 value::{Dict, Map},
8};
9use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping, RemappingDiscovery};
10use rayon::prelude::*;
11use std::{
12 borrow::Cow,
13 cmp::Reverse,
14 collections::{
15 BTreeMap, BTreeSet, HashMap, HashSet, btree_map::Entry, hash_map::Entry as HashEntry,
16 },
17 fs,
18 path::{Component, MAIN_SEPARATOR, Path, PathBuf},
19};
20
21const GENERATED_REMAPPINGS_KEY: &str = "__generated_remappings";
22
23#[derive(Default)]
24struct RemappingsOutput {
25 remappings: Vec<Remapping>,
27 generated_contextual_remappings: Vec<Remapping>,
32}
33
34#[derive(Clone, Debug, Default)]
36pub struct Remappings {
37 remappings: Vec<Remapping>,
39 project_paths: Vec<Remapping>,
42}
43
44impl Remappings {
45 pub const fn new() -> Self {
47 Self { remappings: Vec::new(), project_paths: Vec::new() }
48 }
49
50 pub const fn new_with_remappings(remappings: Vec<Remapping>) -> Self {
52 Self { remappings, project_paths: Vec::new() }
53 }
54
55 pub fn with_figment(mut self, figment: &Figment) -> Self {
57 let mut add_project_remapping = |path: &str| {
58 if let Ok(path) = figment.find_value(path)
59 && let Some(path) = path.into_string()
60 {
61 let remapping =
62 Remapping { context: None, name: format!("{path}/"), path: format!("{path}/") };
63 self.project_paths.push(remapping);
64 }
65 };
66 add_project_remapping("src");
67 add_project_remapping("test");
68 add_project_remapping("script");
69 self
70 }
71
72 pub fn into_inner(self) -> Vec<Remapping> {
74 let mut seen = HashSet::new();
75 self.remappings
76 .iter()
77 .filter(|r| seen.insert((r.context.as_deref(), r.name.as_str())))
78 .cloned()
79 .collect()
80 }
81
82 fn push(&mut self, remapping: Remapping) -> bool {
84 if remapping.name.ends_with(".sol") && !remapping.path.ends_with(".sol") {
86 return false;
87 }
88
89 if self.remappings.iter().any(|existing| {
90 if remapping.name.ends_with(".sol") {
91 return existing.name == remapping.name
93 && existing.context == remapping.context
94 && existing.path == remapping.path;
95 }
96
97 remapping_name_is_prefix(&existing.name, &remapping.name)
108 && existing.context == remapping.context
109 }) {
110 return false;
111 };
112
113 if self
116 .project_paths
117 .iter()
118 .any(|project_path| remapping.name.eq_ignore_ascii_case(&project_path.name))
119 {
120 return false;
121 };
122
123 self.remappings.push(remapping);
124 true
125 }
126
127 pub fn extend(&mut self, remappings: Vec<Remapping>) {
129 for remapping in remappings {
130 self.push(remapping);
131 }
132 }
133
134 pub fn generated_from_figment(figment: &Figment) -> Vec<Remapping> {
136 figment.extract_inner(GENERATED_REMAPPINGS_KEY).unwrap_or_default()
137 }
138
139 pub fn extend_with_config_remappings(
143 &mut self,
144 config_remappings: Vec<Remapping>,
145 generated_contextual_remappings: &[Remapping],
146 ) {
147 let authoritative = self.remappings.clone();
148 let mut suppressed = HashSet::new();
149 let mut overlays = Vec::new();
150 for (index, remapping) in config_remappings.iter().enumerate().filter(|(_, remapping)| {
151 remapping.context.is_some() && generated_contextual_remappings.contains(remapping)
152 }) {
153 if let Some(contextual) = contextual_overlays(&authoritative, remapping) {
154 overlays.extend(contextual);
155 } else {
156 suppressed.insert(index);
157 }
158 }
159 self.extend(overlays);
160 for (index, remapping) in config_remappings.into_iter().enumerate() {
161 if !suppressed.contains(&index) {
162 self.push(remapping);
163 }
164 }
165 }
166}
167
168struct CachedNestedConfig {
169 src: PathBuf,
170 libs: Vec<PathBuf>,
171 remappings: Vec<Remapping>,
172 file_remappings: Vec<Remapping>,
173}
174
175pub struct RemappingsProvider<'a> {
181 pub auto_detect_remappings: bool,
183 pub lib_paths: Cow<'a, Vec<PathBuf>>,
185 pub root: &'a Path,
188 pub remappings: Result<Vec<Remapping>, Error>,
193}
194
195impl RemappingsProvider<'_> {
196 fn get_remappings(&self, remappings: Vec<Remapping>) -> Result<RemappingsOutput, Error> {
207 trace!("get all remappings from {:?}", self.root);
208 fn insert_closest(
214 mappings: &mut BTreeMap<Option<String>, BTreeMap<String, PathBuf>>,
215 context: Option<String>,
216 key: String,
217 path: PathBuf,
218 ) {
219 let context_mappings = mappings.entry(context).or_default();
220 match context_mappings.entry(key) {
221 Entry::Occupied(mut entry) => {
222 let existing = entry.get();
223 if (path.components().count(), !path.ends_with("src"), &path)
224 < (existing.components().count(), !existing.ends_with("src"), existing)
225 {
226 entry.insert(path);
227 }
228 }
229 Entry::Vacant(entry) => {
230 entry.insert(path);
231 }
232 }
233 }
234
235 let mut user_remappings = Vec::new();
238
239 if let Some(env_remappings) = remappings_from_env_var("DAPP_REMAPPINGS")
241 .or_else(|| remappings_from_env_var("FOUNDRY_REMAPPINGS"))
242 {
243 user_remappings
244 .extend(env_remappings.map_err::<Error, _>(|err| err.to_string().into())?);
245 }
246
247 let remappings_file = self.root.join("remappings.txt");
249 if remappings_file.is_file() {
250 let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
251 let remappings_from_file: Result<Vec<_>, _> =
252 remappings_from_newline(&content).collect();
253 user_remappings
254 .extend(remappings_from_file.map_err::<Error, _>(|err| err.to_string().into())?);
255 }
256
257 user_remappings.extend(remappings);
258 let mut authoritative_user_remappings = user_remappings.clone();
259 for remapping in &mut authoritative_user_remappings {
260 if let Some(context) = &mut remapping.context {
261 *context = self.root.join(&*context).display().to_string();
262 }
263 }
264 let mut all_remappings = Remappings::new_with_remappings(user_remappings);
267
268 if self.auto_detect_remappings {
270 let (nested_foundry_remappings, auto_detected_remappings) = rayon::join(
271 || self.find_nested_foundry_remappings(),
272 || self.auto_detect_remappings(),
273 );
274 let nested_foundry_remappings = nested_foundry_remappings?;
275
276 let configured_package_entries = nested_foundry_remappings
277 .iter()
278 .filter(|(_, _, is_package_entry)| *is_package_entry)
279 .map(|(lib, remapping, _)| (lib.clone(), remapping.clone()))
280 .collect::<Vec<_>>();
281 let RemappingDiscovery { global, contextual } = auto_detected_remappings;
282 let mut global = global
283 .into_iter()
284 .map(|remapping| configured_auto_remapping(remapping, &configured_package_entries))
285 .collect::<Vec<_>>();
286 let mut contextual = contextual
287 .into_iter()
288 .map(|remapping| configured_auto_remapping(remapping, &configured_package_entries))
289 .collect::<Vec<_>>();
290 let safe_alias = |remapping: &Remapping| {
291 !["lib/", "src/", "contracts/"].contains(&remapping.name.as_str())
292 };
293 global.retain(&safe_alias);
294 contextual.retain(safe_alias);
295 let mut targets_by_alias = BTreeMap::<_, BTreeSet<_>>::new();
296 for remapping in global.iter().chain(&contextual) {
297 targets_by_alias
298 .entry(remapping.name.clone())
299 .or_default()
300 .insert(PathBuf::from(&remapping.path));
301 }
302 let ambiguous_aliases = targets_by_alias
303 .into_iter()
304 .filter_map(|(name, targets)| (targets.len() > 1).then_some(name))
305 .collect::<BTreeSet<_>>();
306 let mut lib_remappings = BTreeMap::new();
307 let mut explicit_contextual_remappings = Vec::new();
308 for (_, r, _) in &nested_foundry_remappings {
309 if r.context.is_some()
310 && let Some(overlays) = contextual_overlays(&authoritative_user_remappings, r)
311 {
312 explicit_contextual_remappings.extend(overlays);
313 explicit_contextual_remappings.push(r.clone());
314 }
315 }
316 let mut authoritative_remappings = authoritative_user_remappings;
317 authoritative_remappings.extend(explicit_contextual_remappings.iter().cloned());
318 let mut contextual_remappings = Vec::new();
319 for (lib, r, is_package_entry) in &nested_foundry_remappings {
320 if r.context.is_some() {
321 continue;
322 }
323 if !is_package_entry
327 && global.iter().chain(&contextual).any(|auto| {
328 auto.name == r.name
329 && Path::new(&r.path) != Path::new(&auto.path)
330 && (auto
331 .context
332 .as_deref()
333 .is_some_and(|context| Path::new(context) == lib)
334 || (auto.context == r.context
335 && Path::new(&r.path).starts_with(&auto.path)))
336 })
337 {
338 let mut contextual = r.clone();
339 contextual.context = Some(format!("{}/", lib.display()));
340 if let Some(overlays) =
341 contextual_overlays(&authoritative_remappings, &contextual)
342 {
343 contextual_remappings.extend(overlays);
344 contextual_remappings.push(contextual);
345 }
346 }
347 insert_closest(
348 &mut lib_remappings,
349 r.context.clone(),
350 r.name.clone(),
351 PathBuf::from(&r.path),
352 );
353 }
354 for remapping in contextual
355 .into_iter()
356 .filter(|remapping| ambiguous_aliases.contains(&remapping.name))
357 .flat_map(expand_scoped_contextual_remapping)
358 {
359 if let Some(overlays) = contextual_overlays(&authoritative_remappings, &remapping) {
360 contextual_remappings.extend(overlays);
361 contextual_remappings.push(remapping);
362 }
363 }
364 contextual_remappings.sort_by(|a, b| {
365 let a_context = a.context.as_deref().unwrap_or_default();
366 let b_context = b.context.as_deref().unwrap_or_default();
367 Path::new(b_context)
368 .components()
369 .count()
370 .cmp(&Path::new(a_context).components().count())
371 .then_with(|| a_context.cmp(b_context))
372 });
373 for r in global {
374 insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
375 }
376
377 let explicit_remappings = all_remappings
378 .remappings
379 .iter()
380 .map(|r| relative_remapping_preserving_context_boundary(r.clone(), self.root))
381 .collect::<Vec<_>>();
382 explicit_contextual_remappings.sort_by(|a, b| {
383 let a_context = a.context.as_deref().unwrap_or_default();
384 let b_context = b.context.as_deref().unwrap_or_default();
385 Path::new(b_context)
386 .components()
387 .count()
388 .cmp(&Path::new(a_context).components().count())
389 .then_with(|| a_context.cmp(b_context))
390 });
391 for contextual in explicit_contextual_remappings {
392 let relative =
393 relative_remapping_preserving_context_boundary(contextual.clone(), self.root);
394 if !explicit_remappings.contains(&relative) {
395 all_remappings.push(contextual);
396 }
397 }
398 let mut generated_remappings = Vec::new();
399 for contextual in contextual_remappings {
400 let relative =
401 relative_remapping_preserving_context_boundary(contextual.clone(), self.root);
402 if !explicit_remappings.contains(&relative)
403 && all_remappings.push(contextual.clone())
404 {
405 generated_remappings.push(contextual);
406 }
407 }
408 all_remappings.extend(
409 lib_remappings
410 .into_iter()
411 .flat_map(|(context, remappings)| {
412 remappings.into_iter().map(move |(name, path)| Remapping {
413 context: context.clone(),
414 name,
415 path: path.to_string_lossy().into(),
416 })
417 })
418 .collect(),
419 );
420
421 return Ok(RemappingsOutput {
422 remappings: all_remappings.into_inner(),
423 generated_contextual_remappings: generated_remappings,
424 });
425 }
426
427 Ok(RemappingsOutput { remappings: all_remappings.into_inner(), ..Default::default() })
428 }
429
430 fn find_nested_foundry_remappings(&self) -> Result<Vec<(PathBuf, Remapping, bool)>, Error> {
432 let root = dunce::canonicalize(self.root).unwrap_or_else(|_| self.root.to_path_buf());
433 let mut pending = self
434 .lib_paths
435 .iter()
436 .map(|path| if path.is_absolute() { path.clone() } else { self.root.join(path) })
437 .flat_map(foundry_toml_dir_entries)
438 .collect::<BTreeSet<_>>();
439 let mut seen = HashSet::from([root.clone()]);
440 let mut configs = HashMap::<PathBuf, Option<CachedNestedConfig>>::new();
441 let mut remappings = Vec::new();
442
443 while let Some(entry) = pending.pop_first() {
444 if entry.canonical == root {
445 continue;
446 }
447
448 let config = match configs.entry(entry.canonical.clone()) {
452 HashEntry::Occupied(config) => config.into_mut(),
453 HashEntry::Vacant(config) => {
454 trace!(lib = ?entry.canonical, "find all remappings of nested foundry.toml");
455 config.insert(load_nested_config(&entry.canonical)?)
456 }
457 };
458 let Some(config) = config else {
459 continue;
460 };
461
462 remappings.extend(config.remappings.iter().cloned().map(|remapping| {
463 (
464 entry.path.clone(),
465 rebase_nested_remapping(remapping, &entry.canonical, &entry.path),
466 false,
467 )
468 }));
469 remappings.extend(config.file_remappings.iter().cloned().map(|remapping| {
470 (entry.path.clone(), RelativeRemapping::new(remapping, &entry.path).into(), false)
471 }));
472
473 if !fs::symlink_metadata(&entry.path)
476 .is_ok_and(|metadata| metadata.file_type().is_symlink())
477 && seen.insert(entry.canonical.clone())
478 {
479 for lib in &config.libs {
480 let lib = if lib.is_absolute() { lib.clone() } else { entry.path.join(lib) };
481 pending.extend(foundry_toml_dir_entries(lib).into_iter().filter(|entry| {
482 !fs::symlink_metadata(&entry.path)
483 .is_ok_and(|metadata| metadata.file_type().is_symlink())
484 }));
485 }
486 }
487
488 let standard_src = [Path::new("src"), Path::new("contracts"), Path::new("lib")];
492 if (!standard_src.contains(&config.src.as_path())
493 || !entry.canonical.join(&config.src).is_dir())
494 && let Some(name) = entry.path.file_name().and_then(|name| name.to_str())
495 {
496 let mut remapping = Remapping {
497 context: None,
498 name: format!("{name}/"),
499 path: entry.path.join(&config.src).display().to_string(),
500 };
501 if !remapping.path.ends_with('/') {
502 remapping.path.push('/');
503 }
504 remappings.push((entry.path, remapping, true));
505 }
506 }
507
508 Ok(remappings)
509 }
510
511 fn auto_detect_remappings(&self) -> RemappingDiscovery {
513 let mut discovery = RemappingDiscovery::default();
514 for mut current in self
515 .lib_paths
516 .par_iter()
517 .map(|lib| {
518 let lib = self.root.join(lib);
519 trace!(?lib, "find all remappings");
520 Remapping::find_many_with_context(&lib)
521 })
522 .collect::<Vec<_>>()
523 {
524 discovery.global.append(&mut current.global);
525 discovery.contextual.append(&mut current.contextual);
526 }
527 discovery
528 }
529}
530
531fn load_nested_config(root: &Path) -> Result<Option<CachedNestedConfig>, Error> {
532 let figment = Config::with_root(root).to_figment(FigmentProviders::Cast);
533 let Ok(config) = Config::from_figment_fallback(figment) else { return Ok(None) };
534 let src = config.src.clone();
535 let libs = config.libs.clone();
536 let remappings = config.sanitized().remappings.into_iter().map(Remapping::from).collect();
537 let remappings_file = root.join("remappings.txt");
538 let file_remappings = if remappings_file.is_file() {
539 let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
540 remappings_from_newline(&content)
541 .collect::<Result<Vec<_>, _>>()
542 .map_err::<Error, _>(|err| err.to_string().into())?
543 } else {
544 Vec::new()
545 };
546 Ok(Some(CachedNestedConfig { src, libs, remappings, file_remappings }))
547}
548
549fn remapping_name_is_prefix(prefix: &str, name: &str) -> bool {
550 let prefix = prefix.strip_suffix('/').unwrap_or(prefix);
551 let name = name.strip_suffix('/').unwrap_or(name);
552 prefix == name || name.strip_prefix(prefix).is_some_and(|suffix| suffix.starts_with('/'))
553}
554
555fn contextual_overlays(
556 authoritative: &[Remapping],
557 refinement: &Remapping,
558) -> Option<Vec<Remapping>> {
559 let applicable = |mapping: &&Remapping| {
560 mapping.context.as_deref().is_none_or(|context| {
561 refinement
562 .context
563 .as_deref()
564 .is_some_and(|refinement| context_starts_with(refinement, context))
565 })
566 };
567 if authoritative
568 .iter()
569 .filter(applicable)
570 .any(|mapping| remapping_name_is_prefix(&mapping.name, &refinement.name))
571 {
572 return None;
573 }
574 let mut overlays = authoritative
575 .iter()
576 .filter(applicable)
577 .filter(|mapping| remapping_name_is_prefix(&refinement.name, &mapping.name))
578 .cloned()
579 .collect::<Vec<_>>();
580 overlays.sort_by_key(|mapping| Reverse(mapping.name.len()));
581 for overlay in &mut overlays {
582 overlay.context.clone_from(&refinement.context);
583 }
584 Some(overlays)
585}
586
587fn context_starts_with(path: &str, base: &str) -> bool {
588 #[cfg(windows)]
589 {
590 use path_slash::PathBufExt as _;
591
592 let path = PathBuf::from_slash(path);
593 let base = PathBuf::from_slash(base);
594 return path.starts_with(base);
595 }
596 #[cfg(not(windows))]
597 Path::new(path).starts_with(base)
598}
599
600fn expand_scoped_contextual_remapping(remapping: Remapping) -> Vec<Remapping> {
603 let scope = remapping.name.trim_end_matches('/');
604 let path = Path::new(&remapping.path);
605 if !scope.starts_with('@')
606 || path.file_name().and_then(|name| name.to_str()) != Some(scope)
607 || path.parent().and_then(|parent| parent.file_name()).and_then(|name| name.to_str())
608 != Some("node_modules")
609 {
610 return vec![remapping];
611 }
612
613 let Ok(entries) = fs::read_dir(path) else { return vec![remapping] };
614 let mut packages = Vec::new();
615 for entry in entries {
616 let Ok(entry) = entry else { return vec![remapping] };
617 if !entry.path().is_dir() {
618 continue;
619 }
620 let Ok(name) = entry.file_name().into_string() else { return vec![remapping] };
621 let mut path = entry.path().display().to_string();
622 if !path.ends_with(['/', '\\']) {
623 path.push(MAIN_SEPARATOR);
624 }
625 packages.push(Remapping {
626 context: remapping.context.clone(),
627 name: format!("{scope}/{name}/"),
628 path,
629 });
630 }
631 packages.sort_by(|a, b| a.name.cmp(&b.name));
632 if packages.is_empty() { vec![remapping] } else { packages }
633}
634
635fn configured_auto_remapping(
636 mut remapping: Remapping,
637 configured_package_entries: &[(PathBuf, Remapping)],
638) -> Remapping {
639 let path = Path::new(&remapping.path);
640 let context = remapping.context.as_deref().map(Path::new);
641 if let Some((_, (_, configured))) = configured_package_entries
642 .iter()
643 .filter(|(lib, configured)| {
644 configured.name == remapping.name
645 && context.is_none_or(|owner| lib != owner && lib.starts_with(owner))
646 })
647 .filter_map(|entry @ (lib, _)| {
648 if path.starts_with(lib) {
649 Some(((0, usize::MAX - lib.components().count()), entry))
650 } else if context.is_some_and(|context| lib.starts_with(context))
651 && lib.starts_with(path)
652 {
653 Some(((1, lib.components().count()), entry))
654 } else {
655 None
656 }
657 })
658 .min_by(|(rank_a, (lib_a, _)), (rank_b, (lib_b, _))| {
659 rank_a.cmp(rank_b).then_with(|| lib_a.cmp(lib_b))
660 })
661 {
662 remapping.path.clone_from(&configured.path);
663 }
664 remapping
665}
666
667fn rebase_nested_remapping(
668 mut remapping: Remapping,
669 canonical: &Path,
670 lexical: &Path,
671) -> Remapping {
672 let normalize = |path: &Path| {
673 let mut normalized = PathBuf::new();
674 for component in path.components() {
675 match component {
676 Component::CurDir => {}
677 Component::ParentDir => {
678 normalized.pop();
679 }
680 _ => normalized.push(component.as_os_str()),
681 }
682 }
683 normalized
684 };
685 let rebase = |value: &str| {
686 let path = Path::new(value);
687 path.strip_prefix(canonical)
688 .map(|relative| lexical.join(relative).display().to_string())
689 .unwrap_or_else(|_| value.to_string())
690 };
691 remapping.path = rebase(&remapping.path);
692 if let Some(context) = &mut remapping.context {
693 let has_boundary = context.ends_with(['/', '\\']);
694 *context = if Path::new(context).is_absolute() {
695 rebase(context)
696 } else {
697 normalize(&lexical.join(&*context)).display().to_string()
698 };
699 if has_boundary && !context.ends_with(['/', '\\']) {
700 context.push(MAIN_SEPARATOR);
701 }
702 }
703 remapping
704}
705
706pub fn relative_remapping_preserving_context_boundary(
707 remapping: Remapping,
708 root: &Path,
709) -> RelativeRemapping {
710 let has_boundary =
711 remapping.context.as_deref().is_some_and(|context| context.ends_with(['/', '\\']));
712 let mut remapping = RelativeRemapping::new(remapping, root);
713 if has_boundary
715 && let Some(context) = &mut remapping.context
716 && !context.ends_with(MAIN_SEPARATOR)
717 {
718 if context.ends_with(['/', '\\']) {
719 context.pop();
720 }
721 context.push(MAIN_SEPARATOR);
722 }
723 remapping
724}
725
726impl Provider for RemappingsProvider<'_> {
727 fn metadata(&self) -> Metadata {
728 Metadata::named("Remapping Provider")
729 }
730
731 fn data(&self) -> Result<Map<Profile, Dict>, Error> {
732 let output = match &self.remappings {
733 Ok(remappings) => self.get_remappings(remappings.clone()),
734 Err(err) => {
735 if let figment::error::Kind::MissingField(_) = err.kind {
736 self.get_remappings(vec![])
737 } else {
738 return Err(err.clone());
739 }
740 }
741 }?;
742
743 let remappings = output
745 .remappings
746 .into_iter()
747 .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string())
748 .collect::<Vec<_>>();
749 let generated_remappings = output
750 .generated_contextual_remappings
751 .into_iter()
752 .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string())
753 .collect::<Vec<_>>();
754
755 Ok(Map::from([(
756 Config::selected_profile(),
757 Dict::from([
758 ("remappings".to_string(), figment::value::Value::from(remappings)),
759 (
760 GENERATED_REMAPPINGS_KEY.to_string(),
761 figment::value::Value::from(generated_remappings),
762 ),
763 ]),
764 )]))
765 }
766
767 fn profile(&self) -> Option<Profile> {
768 Some(Config::selected_profile())
769 }
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[cfg(unix)]
777 use std::os::unix::fs::symlink;
778 #[cfg(unix)]
779 use tempfile::tempdir;
780
781 #[cfg(unix)]
782 #[test]
783 fn nested_remappings_ignore_symlinks_back_to_the_project_root() {
784 let temp = tempdir().unwrap();
785 let root = temp.path();
786 fs::create_dir(root.join("lib")).unwrap();
787 fs::write(root.join(Config::FILE_NAME), "").unwrap();
788 symlink(root, root.join("lib/self")).unwrap();
789 let libs = vec![PathBuf::from("lib")];
790 let provider = RemappingsProvider {
791 auto_detect_remappings: true,
792 lib_paths: Cow::Borrowed(&libs),
793 root,
794 remappings: Ok(vec![]),
795 };
796
797 assert!(provider.find_nested_foundry_remappings().unwrap().is_empty());
798 }
799
800 #[cfg(unix)]
801 #[test]
802 fn nested_remappings_visit_each_config_once_across_a_dependency_cycle() {
803 let temp = tempdir().unwrap();
804 let root = temp.path().join("project");
805 let dependency = temp.path().join("dependency");
806 fs::create_dir_all(root.join("lib")).unwrap();
807 fs::create_dir_all(dependency.join("lib")).unwrap();
808 fs::create_dir(dependency.join("custom-source")).unwrap();
809 fs::write(root.join(Config::FILE_NAME), "").unwrap();
810 fs::write(
811 dependency.join(Config::FILE_NAME),
812 "[profile.default]\nsrc = \"custom-source\"\n",
813 )
814 .unwrap();
815 symlink(&dependency, root.join("lib/dependency-alias")).unwrap();
816 symlink(&root, dependency.join("lib/back")).unwrap();
817 let libs = vec![PathBuf::from("lib")];
818 let provider = RemappingsProvider {
819 auto_detect_remappings: true,
820 lib_paths: Cow::Borrowed(&libs),
821 root: &root,
822 remappings: Ok(vec![]),
823 };
824
825 assert_eq!(
826 provider.find_nested_foundry_remappings().unwrap(),
827 vec![(
828 root.join("lib/dependency-alias"),
829 Remapping {
830 context: None,
831 name: "dependency-alias/".to_string(),
832 path: format!("{}/", root.join("lib/dependency-alias/custom-source").display()),
833 },
834 true,
835 )]
836 );
837 }
838
839 #[cfg(unix)]
840 #[test]
841 fn nested_remappings_traverse_physical_dependency_after_symlink_alias() {
842 let temp = tempdir().unwrap();
843 let root = temp.path().join("project");
844 let dependency = root.join("lib/z-dependency");
845 let nested = dependency.join("lib/nested");
846 fs::create_dir_all(&nested).unwrap();
847 fs::create_dir(nested.join("custom-source")).unwrap();
848 fs::write(root.join(Config::FILE_NAME), "").unwrap();
849 fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\nlibs = [\"lib\"]\n")
850 .unwrap();
851 fs::write(nested.join(Config::FILE_NAME), "[profile.default]\nsrc = \"custom-source\"\n")
852 .unwrap();
853 symlink(&dependency, root.join("lib/a-dependency-alias")).unwrap();
854 let libs = vec![PathBuf::from("lib")];
855 let provider = RemappingsProvider {
856 auto_detect_remappings: true,
857 lib_paths: Cow::Borrowed(&libs),
858 root: &root,
859 remappings: Ok(vec![]),
860 };
861
862 assert!(provider.find_nested_foundry_remappings().unwrap().contains(&(
863 nested.clone(),
864 Remapping {
865 context: None,
866 name: "nested/".to_string(),
867 path: format!("{}/", nested.join("custom-source").display()),
868 },
869 true,
870 )));
871 }
872
873 #[test]
874 fn relative_remapping_preserves_context_directory_boundary() {
875 let remapping = Remapping {
876 context: Some("lib/outer/".to_string()),
877 name: "inner/".to_string(),
878 path: format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}lib{MAIN_SEPARATOR}inner"),
879 };
880
881 let remapping = relative_remapping_preserving_context_boundary(remapping, Path::new("."));
882 assert!(remapping.context.as_ref().unwrap().ends_with(MAIN_SEPARATOR));
883 assert_eq!(remapping.to_string(), "lib/outer/:inner/=lib/outer/lib/inner/");
884 }
885
886 #[test]
887 fn config_merge_only_suppresses_generated_refinements() {
888 let cli = Remapping {
889 context: None,
890 name: "pkg/sub/".to_string(),
891 path: "src/local/".to_string(),
892 };
893 let contextual = Remapping {
894 context: Some("lib/dep/".to_string()),
895 name: "pkg/".to_string(),
896 path: "lib/dep/vendor/pkg/".to_string(),
897 };
898 let mut explicit = Remappings::new_with_remappings(vec![cli.clone()]);
899 explicit.extend_with_config_remappings(vec![contextual.clone()], &[]);
900 assert_eq!(explicit.into_inner(), vec![cli.clone(), contextual.clone()]);
901
902 let global = Remapping {
903 context: None,
904 name: "pkg/".to_string(),
905 path: "lib/dep/lib/pkg/".to_string(),
906 };
907 let refinement = Remapping {
908 context: Some("lib/dep/".to_string()),
909 name: "pkg/".to_string(),
910 path: "lib/dep/lib/pkg/contracts/".to_string(),
911 };
912 let cli_deep = Remapping {
913 context: None,
914 name: "pkg/sub/deep/".to_string(),
915 path: "src/deep/".to_string(),
916 };
917 let mut generated = Remappings::new_with_remappings(vec![cli.clone(), cli_deep.clone()]);
918 generated.extend_with_config_remappings(
919 vec![refinement.clone(), global.clone()],
920 std::slice::from_ref(&refinement),
921 );
922 let mut overlay = cli.clone();
923 overlay.context.clone_from(&refinement.context);
924 let mut deep_overlay = cli_deep.clone();
925 deep_overlay.context.clone_from(&refinement.context);
926 assert_eq!(
927 generated.into_inner(),
928 vec![cli.clone(), cli_deep, deep_overlay, overlay, refinement, global.clone()]
929 );
930
931 let broad_cli =
932 Remapping { context: None, name: "pkg/".to_string(), path: "src/local/".to_string() };
933 let mut generated = Remappings::new_with_remappings(vec![broad_cli.clone(), cli.clone()]);
934 generated.extend_with_config_remappings(
935 vec![contextual.clone(), global],
936 std::slice::from_ref(&contextual),
937 );
938 assert_eq!(generated.into_inner(), vec![broad_cli, cli]);
939
940 let contextual_cli = Remapping {
941 context: Some("lib/dep/".to_string()),
942 name: "pkg/".to_string(),
943 path: "src/contextual/".to_string(),
944 };
945 let nested_refinement = Remapping {
946 context: Some("lib/dep/lib/nested/".to_string()),
947 name: "pkg/".to_string(),
948 path: "lib/dep/lib/nested/lib/pkg/".to_string(),
949 };
950 let mut generated = Remappings::new_with_remappings(vec![contextual_cli.clone()]);
951 generated.extend_with_config_remappings(
952 vec![nested_refinement.clone()],
953 std::slice::from_ref(&nested_refinement),
954 );
955 assert_eq!(generated.into_inner(), vec![contextual_cli]);
956 }
957
958 #[cfg(windows)]
959 #[test]
960 fn contextual_overlay_accepts_mixed_windows_separators() {
961 let authoritative = Remapping {
962 context: Some(r"C:\workspace\lib/a/".to_string()),
963 name: "shared/".to_string(),
964 path: "src/override/".to_string(),
965 };
966 let refinement = Remapping {
967 context: Some(r"C:\workspace\lib\a\lib\x\".to_string()),
968 name: "shared/".to_string(),
969 path: "lib/a/lib/x/lib/shared/src/".to_string(),
970 };
971
972 assert!(contextual_overlays(&[authoritative], &refinement).is_none());
973 }
974
975 #[test]
976 fn nested_remapping_groups_are_sorted() {
977 let root = tempfile::tempdir().unwrap();
978 for dependency in ["zeta", "alpha"] {
979 let dependency = root.path().join("lib").join(dependency);
980 fs::create_dir_all(&dependency).unwrap();
981 fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\n").unwrap();
982 fs::write(dependency.join("remappings.txt"), "pkg/=src/\n").unwrap();
983 }
984 let libs = vec![PathBuf::from("lib")];
985 let provider = RemappingsProvider {
986 auto_detect_remappings: true,
987 lib_paths: Cow::Borrowed(&libs),
988 root: root.path(),
989 remappings: Ok(Vec::new()),
990 };
991
992 let dependencies = provider
993 .find_nested_foundry_remappings()
994 .unwrap()
995 .into_iter()
996 .map(|(dependency, _, _)| {
997 dependency.file_name().unwrap().to_string_lossy().into_owned()
998 })
999 .collect::<Vec<_>>();
1000 assert_eq!(dependencies, ["alpha", "alpha", "zeta", "zeta"]);
1001 }
1002
1003 #[test]
1004 fn test_sol_file_remappings() {
1005 let mut remappings = Remappings::new();
1006
1007 remappings.push(Remapping {
1009 context: None,
1010 name: "MyContract.sol".to_string(),
1011 path: "implementations/Contract1.sol".to_string(),
1012 });
1013
1014 remappings.push(Remapping {
1016 context: None,
1017 name: "MyContract.sol".to_string(),
1018 path: "implementations/Contract2.sol".to_string(),
1019 });
1020
1021 remappings.push(Remapping {
1023 context: None,
1024 name: "OtherContract.sol".to_string(),
1025 path: "implementations/Contract1.sol".to_string(),
1026 });
1027
1028 remappings.push(Remapping {
1030 context: None,
1031 name: "MyContract.sol".to_string(),
1032 path: "implementations/Contract1.sol".to_string(),
1033 });
1034
1035 remappings.push(Remapping {
1037 context: None,
1038 name: "Invalid.sol".to_string(),
1039 path: "implementations/Contract1.txt".to_string(),
1040 });
1041
1042 let result = remappings.into_inner();
1043 assert_eq!(result.len(), 2, "Should only have 2 valid remappings");
1044
1045 assert!(
1047 result
1048 .iter()
1049 .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract1.sol"),
1050 "Should keep first mapping of MyContract.sol"
1051 );
1052 assert!(
1053 !result
1054 .iter()
1055 .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
1056 "Should keep first mapping of MyContract.sol"
1057 );
1058 assert!(result.iter().any(|r| r.name == "OtherContract.sol" && r.path == "implementations/Contract1.sol"),
1059 "Should allow different source to same target");
1060
1061 assert!(
1063 !result
1064 .iter()
1065 .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
1066 "Should reject same source to different target"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_mixed_remappings() {
1072 let mut remappings = Remappings::new();
1073
1074 remappings.push(Remapping {
1075 context: None,
1076 name: "@openzeppelin-contracts/".to_string(),
1077 path: "lib/openzeppelin-contracts/".to_string(),
1078 });
1079 remappings.push(Remapping {
1080 context: None,
1081 name: "@openzeppelin/contracts/".to_string(),
1082 path: "lib/openzeppelin/contracts/".to_string(),
1083 });
1084
1085 remappings.push(Remapping {
1086 context: None,
1087 name: "MyContract.sol".to_string(),
1088 path: "os/Contract.sol".to_string(),
1089 });
1090
1091 let result = remappings.into_inner();
1092 assert_eq!(result.len(), 3, "Should have 3 remappings");
1093 assert_eq!(result.first().unwrap().name, "@openzeppelin-contracts/");
1094 assert_eq!(result.first().unwrap().path, "lib/openzeppelin-contracts/");
1095 assert_eq!(result.get(1).unwrap().name, "@openzeppelin/contracts/");
1096 assert_eq!(result.get(1).unwrap().path, "lib/openzeppelin/contracts/");
1097 assert_eq!(result.get(2).unwrap().name, "MyContract.sol");
1098 assert_eq!(result.get(2).unwrap().path, "os/Contract.sol");
1099 }
1100
1101 #[test]
1102 fn test_remappings_with_context() {
1103 let mut remappings = Remappings::new();
1104
1105 remappings.push(Remapping {
1107 context: Some("test/".to_string()),
1108 name: "MyContract.sol".to_string(),
1109 path: "test/Contract.sol".to_string(),
1110 });
1111 remappings.push(Remapping {
1112 context: Some("prod/".to_string()),
1113 name: "MyContract.sol".to_string(),
1114 path: "prod/Contract.sol".to_string(),
1115 });
1116
1117 let result = remappings.into_inner();
1118 assert_eq!(result.len(), 2, "Should allow same name with different contexts");
1119 assert!(
1120 result
1121 .iter()
1122 .any(|r| r.context == Some("test/".to_string()) && r.path == "test/Contract.sol")
1123 );
1124 assert!(
1125 result
1126 .iter()
1127 .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol")
1128 );
1129 }
1130
1131 #[test]
1132 fn configured_auto_remapping_uses_nearest_owned_package() {
1133 let configured = vec![
1134 (
1135 PathBuf::from("lib/shared"),
1136 Remapping {
1137 context: None,
1138 name: "shared/".to_string(),
1139 path: "lib/shared/outer-src/".to_string(),
1140 },
1141 ),
1142 (
1143 PathBuf::from("lib/shared/lib/shared"),
1144 Remapping {
1145 context: None,
1146 name: "shared/".to_string(),
1147 path: "lib/shared/lib/shared/inner-src/".to_string(),
1148 },
1149 ),
1150 ];
1151 let candidate = |context: &str, path: &str| Remapping {
1152 context: Some(context.to_string()),
1153 name: "shared/".to_string(),
1154 path: path.to_string(),
1155 };
1156
1157 assert_eq!(
1158 configured_auto_remapping(
1159 candidate("lib/shared/", "lib/shared/lib/shared/src/"),
1160 &configured,
1161 ),
1162 candidate("lib/shared/", "lib/shared/lib/shared/inner-src/")
1163 );
1164 assert_eq!(
1165 configured_auto_remapping(candidate("lib/shared/", "lib/shared/lib/"), &configured),
1166 candidate("lib/shared/", "lib/shared/lib/shared/inner-src/")
1167 );
1168
1169 let nested = candidate("lib/shared/lib/nested/", "lib/shared/lib/nested/lib/shared/src/");
1170 assert_eq!(
1171 configured_auto_remapping(nested.clone(), &configured),
1172 nested,
1173 "configured ancestors must not rewrite remappings owned by nested dependencies",
1174 );
1175 }
1176
1177 #[test]
1178 fn test_root_remapping_prefix_precedence_is_directional() {
1179 let remapping = |name: &str, path: &str| Remapping {
1180 context: None,
1181 name: name.to_string(),
1182 path: path.to_string(),
1183 };
1184
1185 let mut narrow_root =
1186 Remappings::new_with_remappings(vec![remapping("pkg/sub/", "src/local/")]);
1187 narrow_root.extend(vec![remapping("pkg/", "lib/pkg/src/")]);
1188 assert_eq!(
1189 narrow_root.into_inner(),
1190 vec![remapping("pkg/sub/", "src/local/"), remapping("pkg/", "lib/pkg/src/")]
1191 );
1192
1193 let mut broad_root = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]);
1194 broad_root.extend(vec![
1195 remapping("pkg/sub/", "lib/pkg/src/sub/"),
1196 remapping("pkg-other/", "lib/pkg-other/src/"),
1197 ]);
1198 assert_eq!(
1199 broad_root.into_inner(),
1200 vec![remapping("pkg/", "src/local/"), remapping("pkg-other/", "lib/pkg-other/src/")]
1201 );
1202
1203 let mut duplicate = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]);
1204 duplicate.extend(vec![remapping("pkg/", "lib/pkg/src/")]);
1205 assert_eq!(duplicate.remappings, vec![remapping("pkg/", "src/local/")]);
1206
1207 for (existing, incoming) in
1209 [("pkg", "pkg"), ("pkg", "pkg/"), ("pkg/", "pkg"), ("pkg", "pkg/sub/")]
1210 {
1211 let mut duplicate =
1212 Remappings::new_with_remappings(vec![remapping(existing, "src/local/")]);
1213 duplicate.extend(vec![remapping(incoming, "lib/pkg/src/")]);
1214 assert_eq!(
1215 duplicate.remappings,
1216 vec![remapping(existing, "src/local/")],
1217 "{existing} then {incoming}"
1218 );
1219 }
1220
1221 for incoming in ["pkg/", "pkg/sub/"] {
1223 let existing = remapping("pkg//", "src/double-slash/");
1224 let mut distinct = Remappings::new_with_remappings(vec![existing.clone()]);
1225 let incoming = remapping(incoming, "lib/pkg/src/");
1226 distinct.extend(vec![incoming.clone()]);
1227 assert_eq!(distinct.remappings, vec![existing, incoming]);
1228 }
1229
1230 let contextual_remapping = |context: &str, name: &str, path: &str| Remapping {
1231 context: Some(context.to_string()),
1232 name: name.to_string(),
1233 path: path.to_string(),
1234 };
1235 let mut same_context = Remappings::new_with_remappings(vec![contextual_remapping(
1236 "src/",
1237 "pkg/",
1238 "src/local/",
1239 )]);
1240 same_context.extend(vec![contextual_remapping("src/", "pkg/sub/", "lib/pkg/src/sub/")]);
1241 assert_eq!(
1242 same_context.remappings,
1243 vec![contextual_remapping("src/", "pkg/", "src/local/")]
1244 );
1245
1246 let mut different_context = Remappings::new_with_remappings(vec![contextual_remapping(
1247 "src/",
1248 "pkg/",
1249 "src/local/",
1250 )]);
1251 different_context.extend(vec![contextual_remapping(
1252 "test/",
1253 "pkg/sub/",
1254 "lib/pkg/src/sub/",
1255 )]);
1256 assert_eq!(
1257 different_context.remappings,
1258 vec![
1259 contextual_remapping("src/", "pkg/", "src/local/"),
1260 contextual_remapping("test/", "pkg/sub/", "lib/pkg/src/sub/"),
1261 ]
1262 );
1263
1264 let mut narrow_root_without_slash =
1265 Remappings::new_with_remappings(vec![remapping("pkg/sub", "src/local/")]);
1266 narrow_root_without_slash.extend(vec![remapping("pkg", "lib/pkg/src/")]);
1267 assert_eq!(
1268 narrow_root_without_slash.remappings,
1269 vec![remapping("pkg/sub", "src/local/"), remapping("pkg", "lib/pkg/src/")]
1270 );
1271
1272 let mut broad_root_without_slash =
1273 Remappings::new_with_remappings(vec![remapping("pkg", "src/local/")]);
1274 broad_root_without_slash.extend(vec![
1275 remapping("pkg/sub", "lib/pkg/src/sub/"),
1276 remapping("pkg-other", "lib/pkg-other/src/"),
1277 ]);
1278 assert_eq!(
1279 broad_root_without_slash.remappings,
1280 vec![remapping("pkg", "src/local/"), remapping("pkg-other", "lib/pkg-other/src/")]
1281 );
1282 }
1283}