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