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 .flat_map(expand_scoped_contextual_remapping)
360 {
361 if let Some(overlays) = contextual_overlays(&authoritative_remappings, &remapping) {
362 contextual_remappings.extend(overlays);
363 contextual_remappings.push(remapping);
364 }
365 }
366 contextual_remappings.sort_by(|a, b| {
367 let a_context = a.context.as_deref().unwrap_or_default();
368 let b_context = b.context.as_deref().unwrap_or_default();
369 Path::new(b_context)
370 .components()
371 .count()
372 .cmp(&Path::new(a_context).components().count())
373 .then_with(|| a_context.cmp(b_context))
374 });
375 for r in global {
376 insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
377 }
378
379 let explicit_remappings = all_remappings
380 .remappings
381 .iter()
382 .map(|r| relative_remapping_preserving_context_boundary(r.clone(), self.root))
383 .collect::<Vec<_>>();
384 explicit_contextual_remappings.sort_by(|a, b| {
385 let a_context = a.context.as_deref().unwrap_or_default();
386 let b_context = b.context.as_deref().unwrap_or_default();
387 Path::new(b_context)
388 .components()
389 .count()
390 .cmp(&Path::new(a_context).components().count())
391 .then_with(|| a_context.cmp(b_context))
392 });
393 for contextual in explicit_contextual_remappings {
394 let relative =
395 relative_remapping_preserving_context_boundary(contextual.clone(), self.root);
396 if !explicit_remappings.contains(&relative) {
397 all_remappings.push(contextual);
398 }
399 }
400 let mut generated_remappings = Vec::new();
401 for contextual in contextual_remappings {
402 let relative =
403 relative_remapping_preserving_context_boundary(contextual.clone(), self.root);
404 if !explicit_remappings.contains(&relative)
405 && all_remappings.push(contextual.clone())
406 {
407 generated_remappings.push(contextual);
408 }
409 }
410 all_remappings.extend(
411 lib_remappings
412 .into_iter()
413 .flat_map(|(context, remappings)| {
414 remappings.into_iter().map(move |(name, path)| Remapping {
415 context: context.clone(),
416 name,
417 path: path.to_string_lossy().into(),
418 })
419 })
420 .collect(),
421 );
422
423 return Ok(RemappingsOutput {
424 remappings: all_remappings.into_inner(),
425 generated_contextual_remappings: generated_remappings,
426 });
427 }
428
429 Ok(RemappingsOutput { remappings: all_remappings.into_inner(), ..Default::default() })
430 }
431
432 fn find_nested_foundry_remappings(&self) -> Result<Vec<(PathBuf, Remapping, bool)>, Error> {
434 let root = dunce::canonicalize(self.root).unwrap_or_else(|_| self.root.to_path_buf());
435 let mut pending = self
436 .lib_paths
437 .iter()
438 .map(|path| if path.is_absolute() { path.clone() } else { self.root.join(path) })
439 .flat_map(foundry_toml_dir_entries)
440 .collect::<BTreeSet<_>>();
441 let mut seen = HashSet::from([root.clone()]);
442 let mut configs = HashMap::<PathBuf, Option<CachedNestedConfig>>::new();
443 let mut remappings = Vec::new();
444
445 while let Some(entry) = pending.pop_first() {
446 if entry.canonical == root {
447 continue;
448 }
449
450 let config = match configs.entry(entry.canonical.clone()) {
454 HashEntry::Occupied(config) => config.into_mut(),
455 HashEntry::Vacant(config) => {
456 trace!(lib = ?entry.canonical, "find all remappings of nested foundry.toml");
457 config.insert(load_nested_config(&entry.canonical)?)
458 }
459 };
460 let Some(config) = config else {
461 continue;
462 };
463
464 remappings.extend(config.remappings.iter().cloned().map(|remapping| {
465 (
466 entry.path.clone(),
467 rebase_nested_remapping(remapping, &entry.canonical, &entry.path),
468 false,
469 )
470 }));
471 remappings.extend(config.file_remappings.iter().cloned().map(|remapping| {
472 (entry.path.clone(), RelativeRemapping::new(remapping, &entry.path).into(), false)
473 }));
474
475 if !fs::symlink_metadata(&entry.path)
478 .is_ok_and(|metadata| metadata.file_type().is_symlink())
479 && seen.insert(entry.canonical.clone())
480 {
481 for lib in &config.libs {
482 let lib = if lib.is_absolute() { lib.clone() } else { entry.path.join(lib) };
483 pending.extend(foundry_toml_dir_entries(lib).into_iter().filter(|entry| {
484 !fs::symlink_metadata(&entry.path)
485 .is_ok_and(|metadata| metadata.file_type().is_symlink())
486 }));
487 }
488 }
489
490 let standard_src = [Path::new("src"), Path::new("contracts"), Path::new("lib")];
494 if (!standard_src.contains(&config.src.as_path())
495 || !entry.canonical.join(&config.src).is_dir())
496 && let Some(name) = entry.path.file_name().and_then(|name| name.to_str())
497 {
498 let mut remapping = Remapping {
499 context: None,
500 name: format!("{name}/"),
501 path: entry.path.join(&config.src).display().to_string(),
502 };
503 if !remapping.path.ends_with('/') {
504 remapping.path.push('/');
505 }
506 remappings.push((entry.path, remapping, true));
507 }
508 }
509
510 Ok(remappings)
511 }
512
513 fn auto_detect_remappings(&self) -> RemappingDiscovery {
515 let mut discovery = RemappingDiscovery::default();
516 for mut current in self
517 .lib_paths
518 .par_iter()
519 .map(|lib| {
520 let lib = self.root.join(lib);
521 trace!(?lib, "find all remappings");
522 Remapping::find_many_with_context(&lib)
523 })
524 .collect::<Vec<_>>()
525 {
526 discovery.global.append(&mut current.global);
527 discovery.contextual.append(&mut current.contextual);
528 }
529 discovery
530 }
531}
532
533fn load_nested_config(root: &Path) -> Result<Option<CachedNestedConfig>, Error> {
534 let figment = Config::with_root(root).to_figment(FigmentProviders::Cast);
535 let Ok(config) = Config::from_figment_fallback(figment) else { return Ok(None) };
536 let src = config.src.clone();
537 let libs = config.libs.clone();
538 let remappings = config.sanitized().remappings.into_iter().map(Remapping::from).collect();
539 let remappings_file = root.join("remappings.txt");
540 let file_remappings = if remappings_file.is_file() {
541 let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
542 remappings_from_newline(&content)
543 .collect::<Result<Vec<_>, _>>()
544 .map_err::<Error, _>(|err| err.to_string().into())?
545 } else {
546 Vec::new()
547 };
548 Ok(Some(CachedNestedConfig { src, libs, remappings, file_remappings }))
549}
550
551fn remapping_name_is_prefix(prefix: &str, name: &str) -> bool {
552 let prefix = prefix.trim_end_matches('/');
553 let name = name.trim_end_matches('/');
554 prefix == name || name.strip_prefix(prefix).is_some_and(|suffix| suffix.starts_with('/'))
555}
556
557fn contextual_overlays(
558 authoritative: &[Remapping],
559 refinement: &Remapping,
560) -> Option<Vec<Remapping>> {
561 let applicable = |mapping: &&Remapping| {
562 mapping.context.as_deref().is_none_or(|context| {
563 refinement
564 .context
565 .as_deref()
566 .is_some_and(|refinement| context_starts_with(refinement, context))
567 })
568 };
569 if authoritative
570 .iter()
571 .filter(applicable)
572 .any(|mapping| remapping_name_is_prefix(&mapping.name, &refinement.name))
573 {
574 return None;
575 }
576 let mut overlays = authoritative
577 .iter()
578 .filter(applicable)
579 .filter(|mapping| remapping_name_is_prefix(&refinement.name, &mapping.name))
580 .cloned()
581 .collect::<Vec<_>>();
582 overlays.sort_by_key(|mapping| Reverse(mapping.name.len()));
583 for overlay in &mut overlays {
584 overlay.context.clone_from(&refinement.context);
585 }
586 Some(overlays)
587}
588
589fn context_starts_with(path: &str, base: &str) -> bool {
590 #[cfg(windows)]
591 {
592 use path_slash::PathBufExt as _;
593
594 let path = PathBuf::from_slash(path);
595 let base = PathBuf::from_slash(base);
596 return path.starts_with(base);
597 }
598 #[cfg(not(windows))]
599 Path::new(path).starts_with(base)
600}
601
602fn expand_scoped_contextual_remapping(remapping: Remapping) -> Vec<Remapping> {
605 let scope = remapping.name.trim_end_matches('/');
606 let path = Path::new(&remapping.path);
607 if !scope.starts_with('@')
608 || path.file_name().and_then(|name| name.to_str()) != Some(scope)
609 || path.parent().and_then(|parent| parent.file_name()).and_then(|name| name.to_str())
610 != Some("node_modules")
611 {
612 return vec![remapping];
613 }
614
615 let Ok(entries) = fs::read_dir(path) else { return vec![remapping] };
616 let mut packages = Vec::new();
617 for entry in entries {
618 let Ok(entry) = entry else { return vec![remapping] };
619 if !entry.path().is_dir() {
620 continue;
621 }
622 let Ok(name) = entry.file_name().into_string() else { return vec![remapping] };
623 let mut path = entry.path().display().to_string();
624 if !path.ends_with(['/', '\\']) {
625 path.push(MAIN_SEPARATOR);
626 }
627 packages.push(Remapping {
628 context: remapping.context.clone(),
629 name: format!("{scope}/{name}/"),
630 path,
631 });
632 }
633 packages.sort_by(|a, b| a.name.cmp(&b.name));
634 if packages.is_empty() { vec![remapping] } else { packages }
635}
636
637fn configured_auto_remapping(
638 mut remapping: Remapping,
639 configured_package_entries: &[(PathBuf, Remapping)],
640) -> Remapping {
641 let path = Path::new(&remapping.path);
642 let context = remapping.context.as_deref().map(Path::new);
643 if let Some((_, (_, configured))) = configured_package_entries
644 .iter()
645 .filter(|(lib, configured)| {
646 configured.name == remapping.name
647 && context.is_none_or(|owner| lib != owner && lib.starts_with(owner))
648 })
649 .filter_map(|entry @ (lib, _)| {
650 if path.starts_with(lib) {
651 Some(((0, usize::MAX - lib.components().count()), entry))
652 } else if context.is_some_and(|context| lib.starts_with(context))
653 && lib.starts_with(path)
654 {
655 Some(((1, lib.components().count()), entry))
656 } else {
657 None
658 }
659 })
660 .min_by(|(rank_a, (lib_a, _)), (rank_b, (lib_b, _))| {
661 rank_a.cmp(rank_b).then_with(|| lib_a.cmp(lib_b))
662 })
663 {
664 remapping.path.clone_from(&configured.path);
665 }
666 remapping
667}
668
669fn rebase_nested_remapping(
670 mut remapping: Remapping,
671 canonical: &Path,
672 lexical: &Path,
673) -> Remapping {
674 let normalize = |path: &Path| {
675 let mut normalized = PathBuf::new();
676 for component in path.components() {
677 match component {
678 Component::CurDir => {}
679 Component::ParentDir => {
680 normalized.pop();
681 }
682 _ => normalized.push(component.as_os_str()),
683 }
684 }
685 normalized
686 };
687 let rebase = |value: &str| {
688 let path = Path::new(value);
689 path.strip_prefix(canonical)
690 .map(|relative| lexical.join(relative).display().to_string())
691 .unwrap_or_else(|_| value.to_string())
692 };
693 remapping.path = rebase(&remapping.path);
694 if let Some(context) = &mut remapping.context {
695 let has_boundary = context.ends_with(['/', '\\']);
696 *context = if Path::new(context).is_absolute() {
697 rebase(context)
698 } else {
699 normalize(&lexical.join(&*context)).display().to_string()
700 };
701 if has_boundary && !context.ends_with(['/', '\\']) {
702 context.push(MAIN_SEPARATOR);
703 }
704 }
705 remapping
706}
707
708pub fn relative_remapping_preserving_context_boundary(
709 remapping: Remapping,
710 root: &Path,
711) -> RelativeRemapping {
712 let has_boundary =
713 remapping.context.as_deref().is_some_and(|context| context.ends_with(['/', '\\']));
714 let mut remapping = RelativeRemapping::new(remapping, root);
715 if has_boundary
717 && let Some(context) = &mut remapping.context
718 && !context.ends_with(MAIN_SEPARATOR)
719 {
720 if context.ends_with(['/', '\\']) {
721 context.pop();
722 }
723 context.push(MAIN_SEPARATOR);
724 }
725 remapping
726}
727
728impl Provider for RemappingsProvider<'_> {
729 fn metadata(&self) -> Metadata {
730 Metadata::named("Remapping Provider")
731 }
732
733 fn data(&self) -> Result<Map<Profile, Dict>, Error> {
734 let output = match &self.remappings {
735 Ok(remappings) => self.get_remappings(remappings.clone()),
736 Err(err) => {
737 if let figment::error::Kind::MissingField(_) = err.kind {
738 self.get_remappings(vec![])
739 } else {
740 return Err(err.clone());
741 }
742 }
743 }?;
744
745 let remappings = output
747 .remappings
748 .into_iter()
749 .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string())
750 .collect::<Vec<_>>();
751 let generated_remappings = output
752 .generated_contextual_remappings
753 .into_iter()
754 .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string())
755 .collect::<Vec<_>>();
756
757 Ok(Map::from([(
758 Config::selected_profile(),
759 Dict::from([
760 ("remappings".to_string(), figment::value::Value::from(remappings)),
761 (
762 GENERATED_REMAPPINGS_KEY.to_string(),
763 figment::value::Value::from(generated_remappings),
764 ),
765 ]),
766 )]))
767 }
768
769 fn profile(&self) -> Option<Profile> {
770 Some(Config::selected_profile())
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 #[cfg(unix)]
779 use std::os::unix::fs::symlink;
780 #[cfg(unix)]
781 use tempfile::tempdir;
782
783 #[cfg(unix)]
784 #[test]
785 fn nested_remappings_ignore_symlinks_back_to_the_project_root() {
786 let temp = tempdir().unwrap();
787 let root = temp.path();
788 fs::create_dir(root.join("lib")).unwrap();
789 fs::write(root.join(Config::FILE_NAME), "").unwrap();
790 symlink(root, root.join("lib/self")).unwrap();
791 let libs = vec![PathBuf::from("lib")];
792 let provider = RemappingsProvider {
793 auto_detect_remappings: true,
794 lib_paths: Cow::Borrowed(&libs),
795 root,
796 remappings: Ok(vec![]),
797 };
798
799 assert!(provider.find_nested_foundry_remappings().unwrap().is_empty());
800 }
801
802 #[cfg(unix)]
803 #[test]
804 fn nested_remappings_visit_each_config_once_across_a_dependency_cycle() {
805 let temp = tempdir().unwrap();
806 let root = temp.path().join("project");
807 let dependency = temp.path().join("dependency");
808 fs::create_dir_all(root.join("lib")).unwrap();
809 fs::create_dir_all(dependency.join("lib")).unwrap();
810 fs::create_dir(dependency.join("custom-source")).unwrap();
811 fs::write(root.join(Config::FILE_NAME), "").unwrap();
812 fs::write(
813 dependency.join(Config::FILE_NAME),
814 "[profile.default]\nsrc = \"custom-source\"\n",
815 )
816 .unwrap();
817 symlink(&dependency, root.join("lib/dependency-alias")).unwrap();
818 symlink(&root, dependency.join("lib/back")).unwrap();
819 let libs = vec![PathBuf::from("lib")];
820 let provider = RemappingsProvider {
821 auto_detect_remappings: true,
822 lib_paths: Cow::Borrowed(&libs),
823 root: &root,
824 remappings: Ok(vec![]),
825 };
826
827 assert_eq!(
828 provider.find_nested_foundry_remappings().unwrap(),
829 vec![(
830 root.join("lib/dependency-alias"),
831 Remapping {
832 context: None,
833 name: "dependency-alias/".to_string(),
834 path: format!("{}/", root.join("lib/dependency-alias/custom-source").display()),
835 },
836 true,
837 )]
838 );
839 }
840
841 #[cfg(unix)]
842 #[test]
843 fn nested_remappings_traverse_physical_dependency_after_symlink_alias() {
844 let temp = tempdir().unwrap();
845 let root = temp.path().join("project");
846 let dependency = root.join("lib/z-dependency");
847 let nested = dependency.join("lib/nested");
848 fs::create_dir_all(&nested).unwrap();
849 fs::create_dir(nested.join("custom-source")).unwrap();
850 fs::write(root.join(Config::FILE_NAME), "").unwrap();
851 fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\nlibs = [\"lib\"]\n")
852 .unwrap();
853 fs::write(nested.join(Config::FILE_NAME), "[profile.default]\nsrc = \"custom-source\"\n")
854 .unwrap();
855 symlink(&dependency, root.join("lib/a-dependency-alias")).unwrap();
856 let libs = vec![PathBuf::from("lib")];
857 let provider = RemappingsProvider {
858 auto_detect_remappings: true,
859 lib_paths: Cow::Borrowed(&libs),
860 root: &root,
861 remappings: Ok(vec![]),
862 };
863
864 assert!(provider.find_nested_foundry_remappings().unwrap().contains(&(
865 nested.clone(),
866 Remapping {
867 context: None,
868 name: "nested/".to_string(),
869 path: format!("{}/", nested.join("custom-source").display()),
870 },
871 true,
872 )));
873 }
874
875 #[test]
876 fn relative_remapping_preserves_context_directory_boundary() {
877 let remapping = Remapping {
878 context: Some("lib/outer/".to_string()),
879 name: "inner/".to_string(),
880 path: format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}lib{MAIN_SEPARATOR}inner"),
881 };
882
883 let remapping = relative_remapping_preserving_context_boundary(remapping, Path::new("."));
884 assert!(remapping.context.as_ref().unwrap().ends_with(MAIN_SEPARATOR));
885 assert_eq!(remapping.to_string(), "lib/outer/:inner/=lib/outer/lib/inner/");
886 }
887
888 #[test]
889 fn config_merge_only_suppresses_generated_refinements() {
890 let cli = Remapping {
891 context: None,
892 name: "pkg/sub/".to_string(),
893 path: "src/local/".to_string(),
894 };
895 let contextual = Remapping {
896 context: Some("lib/dep/".to_string()),
897 name: "pkg/".to_string(),
898 path: "lib/dep/vendor/pkg/".to_string(),
899 };
900 let mut explicit = Remappings::new_with_remappings(vec![cli.clone()]);
901 explicit.extend_with_config_remappings(vec![contextual.clone()], &[]);
902 assert_eq!(explicit.into_inner(), vec![cli.clone(), contextual.clone()]);
903
904 let global = Remapping {
905 context: None,
906 name: "pkg/".to_string(),
907 path: "lib/dep/lib/pkg/".to_string(),
908 };
909 let refinement = Remapping {
910 context: Some("lib/dep/".to_string()),
911 name: "pkg/".to_string(),
912 path: "lib/dep/lib/pkg/contracts/".to_string(),
913 };
914 let cli_deep = Remapping {
915 context: None,
916 name: "pkg/sub/deep/".to_string(),
917 path: "src/deep/".to_string(),
918 };
919 let mut generated = Remappings::new_with_remappings(vec![cli.clone(), cli_deep.clone()]);
920 generated.extend_with_config_remappings(
921 vec![refinement.clone(), global.clone()],
922 std::slice::from_ref(&refinement),
923 );
924 let mut overlay = cli.clone();
925 overlay.context.clone_from(&refinement.context);
926 let mut deep_overlay = cli_deep.clone();
927 deep_overlay.context.clone_from(&refinement.context);
928 assert_eq!(
929 generated.into_inner(),
930 vec![cli.clone(), cli_deep, deep_overlay, overlay, refinement, global.clone()]
931 );
932
933 let broad_cli =
934 Remapping { context: None, name: "pkg/".to_string(), path: "src/local/".to_string() };
935 let mut generated = Remappings::new_with_remappings(vec![broad_cli.clone(), cli.clone()]);
936 generated.extend_with_config_remappings(
937 vec![contextual.clone(), global],
938 std::slice::from_ref(&contextual),
939 );
940 assert_eq!(generated.into_inner(), vec![broad_cli, cli]);
941
942 let contextual_cli = Remapping {
943 context: Some("lib/dep/".to_string()),
944 name: "pkg/".to_string(),
945 path: "src/contextual/".to_string(),
946 };
947 let nested_refinement = Remapping {
948 context: Some("lib/dep/lib/nested/".to_string()),
949 name: "pkg/".to_string(),
950 path: "lib/dep/lib/nested/lib/pkg/".to_string(),
951 };
952 let mut generated = Remappings::new_with_remappings(vec![contextual_cli.clone()]);
953 generated.extend_with_config_remappings(
954 vec![nested_refinement.clone()],
955 std::slice::from_ref(&nested_refinement),
956 );
957 assert_eq!(generated.into_inner(), vec![contextual_cli]);
958 }
959
960 #[cfg(windows)]
961 #[test]
962 fn contextual_overlay_accepts_mixed_windows_separators() {
963 let authoritative = Remapping {
964 context: Some(r"C:\workspace\lib/a/".to_string()),
965 name: "shared/".to_string(),
966 path: "src/override/".to_string(),
967 };
968 let refinement = Remapping {
969 context: Some(r"C:\workspace\lib\a\lib\x\".to_string()),
970 name: "shared/".to_string(),
971 path: "lib/a/lib/x/lib/shared/src/".to_string(),
972 };
973
974 assert!(contextual_overlays(&[authoritative], &refinement).is_none());
975 }
976
977 #[test]
978 fn nested_remapping_groups_are_sorted() {
979 let root = tempfile::tempdir().unwrap();
980 for dependency in ["zeta", "alpha"] {
981 let dependency = root.path().join("lib").join(dependency);
982 fs::create_dir_all(&dependency).unwrap();
983 fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\n").unwrap();
984 fs::write(dependency.join("remappings.txt"), "pkg/=src/\n").unwrap();
985 }
986 let libs = vec![PathBuf::from("lib")];
987 let provider = RemappingsProvider {
988 auto_detect_remappings: true,
989 lib_paths: Cow::Borrowed(&libs),
990 root: root.path(),
991 remappings: Ok(Vec::new()),
992 };
993
994 let dependencies = provider
995 .find_nested_foundry_remappings()
996 .unwrap()
997 .into_iter()
998 .map(|(dependency, _, _)| {
999 dependency.file_name().unwrap().to_string_lossy().into_owned()
1000 })
1001 .collect::<Vec<_>>();
1002 assert_eq!(dependencies, ["alpha", "alpha", "zeta", "zeta"]);
1003 }
1004
1005 #[test]
1006 fn test_sol_file_remappings() {
1007 let mut remappings = Remappings::new();
1008
1009 remappings.push(Remapping {
1011 context: None,
1012 name: "MyContract.sol".to_string(),
1013 path: "implementations/Contract1.sol".to_string(),
1014 });
1015
1016 remappings.push(Remapping {
1018 context: None,
1019 name: "MyContract.sol".to_string(),
1020 path: "implementations/Contract2.sol".to_string(),
1021 });
1022
1023 remappings.push(Remapping {
1025 context: None,
1026 name: "OtherContract.sol".to_string(),
1027 path: "implementations/Contract1.sol".to_string(),
1028 });
1029
1030 remappings.push(Remapping {
1032 context: None,
1033 name: "MyContract.sol".to_string(),
1034 path: "implementations/Contract1.sol".to_string(),
1035 });
1036
1037 remappings.push(Remapping {
1039 context: None,
1040 name: "Invalid.sol".to_string(),
1041 path: "implementations/Contract1.txt".to_string(),
1042 });
1043
1044 let result = remappings.into_inner();
1045 assert_eq!(result.len(), 2, "Should only have 2 valid remappings");
1046
1047 assert!(
1049 result
1050 .iter()
1051 .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract1.sol"),
1052 "Should keep first mapping of MyContract.sol"
1053 );
1054 assert!(
1055 !result
1056 .iter()
1057 .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
1058 "Should keep first mapping of MyContract.sol"
1059 );
1060 assert!(result.iter().any(|r| r.name == "OtherContract.sol" && r.path == "implementations/Contract1.sol"),
1061 "Should allow different source to same target");
1062
1063 assert!(
1065 !result
1066 .iter()
1067 .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
1068 "Should reject same source to different target"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_mixed_remappings() {
1074 let mut remappings = Remappings::new();
1075
1076 remappings.push(Remapping {
1077 context: None,
1078 name: "@openzeppelin-contracts/".to_string(),
1079 path: "lib/openzeppelin-contracts/".to_string(),
1080 });
1081 remappings.push(Remapping {
1082 context: None,
1083 name: "@openzeppelin/contracts/".to_string(),
1084 path: "lib/openzeppelin/contracts/".to_string(),
1085 });
1086
1087 remappings.push(Remapping {
1088 context: None,
1089 name: "MyContract.sol".to_string(),
1090 path: "os/Contract.sol".to_string(),
1091 });
1092
1093 let result = remappings.into_inner();
1094 assert_eq!(result.len(), 3, "Should have 3 remappings");
1095 assert_eq!(result.first().unwrap().name, "@openzeppelin-contracts/");
1096 assert_eq!(result.first().unwrap().path, "lib/openzeppelin-contracts/");
1097 assert_eq!(result.get(1).unwrap().name, "@openzeppelin/contracts/");
1098 assert_eq!(result.get(1).unwrap().path, "lib/openzeppelin/contracts/");
1099 assert_eq!(result.get(2).unwrap().name, "MyContract.sol");
1100 assert_eq!(result.get(2).unwrap().path, "os/Contract.sol");
1101 }
1102
1103 #[test]
1104 fn test_remappings_with_context() {
1105 let mut remappings = Remappings::new();
1106
1107 remappings.push(Remapping {
1109 context: Some("test/".to_string()),
1110 name: "MyContract.sol".to_string(),
1111 path: "test/Contract.sol".to_string(),
1112 });
1113 remappings.push(Remapping {
1114 context: Some("prod/".to_string()),
1115 name: "MyContract.sol".to_string(),
1116 path: "prod/Contract.sol".to_string(),
1117 });
1118
1119 let result = remappings.into_inner();
1120 assert_eq!(result.len(), 2, "Should allow same name with different contexts");
1121 assert!(
1122 result
1123 .iter()
1124 .any(|r| r.context == Some("test/".to_string()) && r.path == "test/Contract.sol")
1125 );
1126 assert!(
1127 result
1128 .iter()
1129 .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol")
1130 );
1131 }
1132
1133 #[test]
1134 fn configured_auto_remapping_uses_nearest_owned_package() {
1135 let configured = vec![
1136 (
1137 PathBuf::from("lib/shared"),
1138 Remapping {
1139 context: None,
1140 name: "shared/".to_string(),
1141 path: "lib/shared/outer-src/".to_string(),
1142 },
1143 ),
1144 (
1145 PathBuf::from("lib/shared/lib/shared"),
1146 Remapping {
1147 context: None,
1148 name: "shared/".to_string(),
1149 path: "lib/shared/lib/shared/inner-src/".to_string(),
1150 },
1151 ),
1152 ];
1153 let candidate = |context: &str, path: &str| Remapping {
1154 context: Some(context.to_string()),
1155 name: "shared/".to_string(),
1156 path: path.to_string(),
1157 };
1158
1159 assert_eq!(
1160 configured_auto_remapping(
1161 candidate("lib/shared/", "lib/shared/lib/shared/src/"),
1162 &configured,
1163 ),
1164 candidate("lib/shared/", "lib/shared/lib/shared/inner-src/")
1165 );
1166 assert_eq!(
1167 configured_auto_remapping(candidate("lib/shared/", "lib/shared/lib/"), &configured),
1168 candidate("lib/shared/", "lib/shared/lib/shared/inner-src/")
1169 );
1170
1171 let nested = candidate("lib/shared/lib/nested/", "lib/shared/lib/nested/lib/shared/src/");
1172 assert_eq!(
1173 configured_auto_remapping(nested.clone(), &configured),
1174 nested,
1175 "configured ancestors must not rewrite remappings owned by nested dependencies",
1176 );
1177 }
1178
1179 #[test]
1180 fn test_root_remapping_prefix_precedence_is_directional() {
1181 let remapping = |name: &str, path: &str| Remapping {
1182 context: None,
1183 name: name.to_string(),
1184 path: path.to_string(),
1185 };
1186
1187 let mut narrow_root =
1188 Remappings::new_with_remappings(vec![remapping("pkg/sub/", "src/local/")]);
1189 narrow_root.extend(vec![remapping("pkg/", "lib/pkg/src/")]);
1190 assert_eq!(
1191 narrow_root.into_inner(),
1192 vec![remapping("pkg/sub/", "src/local/"), remapping("pkg/", "lib/pkg/src/")]
1193 );
1194
1195 let mut broad_root = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]);
1196 broad_root.extend(vec![
1197 remapping("pkg/sub/", "lib/pkg/src/sub/"),
1198 remapping("pkg-other/", "lib/pkg-other/src/"),
1199 ]);
1200 assert_eq!(
1201 broad_root.into_inner(),
1202 vec![remapping("pkg/", "src/local/"), remapping("pkg-other/", "lib/pkg-other/src/")]
1203 );
1204
1205 let mut duplicate = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]);
1206 duplicate.extend(vec![remapping("pkg/", "lib/pkg/src/")]);
1207 assert_eq!(duplicate.remappings, vec![remapping("pkg/", "src/local/")]);
1208
1209 let contextual_remapping = |context: &str, name: &str, path: &str| Remapping {
1210 context: Some(context.to_string()),
1211 name: name.to_string(),
1212 path: path.to_string(),
1213 };
1214 let mut same_context = Remappings::new_with_remappings(vec![contextual_remapping(
1215 "src/",
1216 "pkg/",
1217 "src/local/",
1218 )]);
1219 same_context.extend(vec![contextual_remapping("src/", "pkg/sub/", "lib/pkg/src/sub/")]);
1220 assert_eq!(
1221 same_context.remappings,
1222 vec![contextual_remapping("src/", "pkg/", "src/local/")]
1223 );
1224
1225 let mut different_context = Remappings::new_with_remappings(vec![contextual_remapping(
1226 "src/",
1227 "pkg/",
1228 "src/local/",
1229 )]);
1230 different_context.extend(vec![contextual_remapping(
1231 "test/",
1232 "pkg/sub/",
1233 "lib/pkg/src/sub/",
1234 )]);
1235 assert_eq!(
1236 different_context.remappings,
1237 vec![
1238 contextual_remapping("src/", "pkg/", "src/local/"),
1239 contextual_remapping("test/", "pkg/sub/", "lib/pkg/src/sub/"),
1240 ]
1241 );
1242
1243 let mut narrow_root_without_slash =
1244 Remappings::new_with_remappings(vec![remapping("pkg/sub", "src/local/")]);
1245 narrow_root_without_slash.extend(vec![remapping("pkg", "lib/pkg/src/")]);
1246 assert_eq!(
1247 narrow_root_without_slash.remappings,
1248 vec![remapping("pkg/sub", "src/local/"), remapping("pkg", "lib/pkg/src/")]
1249 );
1250
1251 let mut broad_root_without_slash =
1252 Remappings::new_with_remappings(vec![remapping("pkg", "src/local/")]);
1253 broad_root_without_slash.extend(vec![
1254 remapping("pkg/sub", "lib/pkg/src/sub/"),
1255 remapping("pkg-other", "lib/pkg-other/src/"),
1256 ]);
1257 assert_eq!(
1258 broad_root_without_slash.remappings,
1259 vec![remapping("pkg", "src/local/"), remapping("pkg-other", "lib/pkg-other/src/")]
1260 );
1261 }
1262}