Skip to main content

foundry_config/providers/
remappings.rs

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    /// All explicit, dependency, and auto-detected remappings exposed to the config.
26    remappings: Vec<Remapping>,
27    /// Contextual dependency refinements generated by this provider.
28    ///
29    /// These are tracked so the later CLI merge can distinguish them from equivalent explicit
30    /// contextual remappings.
31    generated_contextual_remappings: Vec<Remapping>,
32}
33
34/// Wrapper types over a `Vec<Remapping>` that only appends unique remappings.
35#[derive(Clone, Debug, Default)]
36pub struct Remappings {
37    /// Remappings.
38    remappings: Vec<Remapping>,
39    /// Source, test and script configured project dirs.
40    /// Remappings of these dirs from libs are ignored.
41    project_paths: Vec<Remapping>,
42}
43
44impl Remappings {
45    /// Create a new `Remappings` wrapper with an empty vector.
46    pub const fn new() -> Self {
47        Self { remappings: Vec::new(), project_paths: Vec::new() }
48    }
49
50    /// Create a new `Remappings` wrapper with a vector of remappings.
51    pub const fn new_with_remappings(remappings: Vec<Remapping>) -> Self {
52        Self { remappings, project_paths: Vec::new() }
53    }
54
55    /// Extract project paths that cannot be remapped by dependencies.
56    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    /// Consumes the wrapper and returns the inner remappings vector.
73    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    /// Push an element to the remappings vector, but only if it's not already present.
83    fn push(&mut self, remapping: Remapping) -> bool {
84        // Special handling for .sol file remappings, only allow one remapping per source file.
85        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                // For .sol files, only prevent duplicate source names in the same context
92                return existing.name == remapping.name
93                    && existing.context == remapping.context
94                    && existing.path == remapping.path;
95            }
96
97            // Autodetected remappings are added from the root project down through its libraries,
98            // so an existing root alias remains authoritative over an equal or more specific
99            // dependency alias. For example, an existing `@utils/=src/` suppresses an incoming
100            // `@utils/libraries/=lib/utils/`, preventing a dependency from overriding part of the
101            // root namespace. The reverse direction is intentional: an existing
102            // `@prb/math/=src/math/` can coexist with an incoming `@prb/=lib/prb/`; the root alias
103            // resolves its subtree while the dependency alias acts as a fallback for the rest of
104            // the namespace.
105            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        // Ignore remappings of root project src, test or script dir.
116        // See <https://github.com/foundry-rs/foundry/issues/3440>.
117        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    /// Extend the remappings vector, leaving out the remappings that are already present.
130    pub fn extend(&mut self, remappings: Vec<Remapping>) {
131        for remapping in remappings {
132            self.push(remapping);
133        }
134    }
135
136    /// Extract generated contextual refinements from a Figment.
137    pub fn generated_from_figment(figment: &Figment) -> Vec<Remapping> {
138        figment.extract_inner(GENERATED_REMAPPINGS_KEY).unwrap_or_default()
139    }
140
141    /// Merge config remappings while preserving CLI precedence over generated refinements.
142    ///
143    /// Narrow CLI aliases are overlaid into generated contexts; explicit contexts are unchanged.
144    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
177/// A figment provider that checks if the remappings were previously set and if they're unset looks
178/// up the fs via
179///   - `DAPP_REMAPPINGS` || `FOUNDRY_REMAPPINGS` env var
180///   - `<root>/remappings.txt` file
181///   - `Remapping::find_many`.
182pub struct RemappingsProvider<'a> {
183    /// Whether to auto detect remappings from the `lib_paths`
184    pub auto_detect_remappings: bool,
185    /// The lib/dependency directories to scan for remappings
186    pub lib_paths: Cow<'a, Vec<PathBuf>>,
187    /// the root path used to turn an absolute `Remapping`, as we're getting it from
188    /// `Remapping::find_many` into a relative one.
189    pub root: &'a Path,
190    /// This contains either:
191    ///   - previously set remappings
192    ///   - a `MissingField` error, which means previous provider didn't set the "remappings" field
193    ///   - other error, like formatting
194    pub remappings: Result<Vec<Remapping>, Error>,
195}
196
197impl RemappingsProvider<'_> {
198    /// Find and parse remappings for the projects
199    ///
200    /// **Order**
201    ///
202    /// Remappings are built in this order (last item takes precedence)
203    /// - Autogenerated remappings
204    /// - toml remappings
205    /// - `remappings.txt`
206    /// - Environment variables
207    /// - CLI parameters
208    fn get_remappings(&self, remappings: Vec<Remapping>) -> Result<RemappingsOutput, Error> {
209        trace!("get all remappings from {:?}", self.root);
210        /// Prioritizes remappings by shortest path, then a `src` target, then lexical path.
211        ///   - ("a", "1/2") over ("a", "1/2/3")
212        ///   - ("a", "1/src") over ("a", "1/lib")
213        ///
214        /// grouped by remapping context
215        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's first just extend the remappings with the ones that were passed in,
238        // without any filtering.
239        let mut user_remappings = Vec::new();
240
241        // check env vars
242        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        // check remappings.txt file
250        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's now use the wrapper to conditionally extend the remappings with the autodetected
267        // ones. We want to avoid duplicates, and the wrapper will handle this for us.
268        let mut all_remappings = Remappings::new_with_remappings(user_remappings);
269
270        // scan all library dirs and autodetect remappings
271        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                // A dependency can intentionally refine an auto-detected package root to its
326                // source directory. Scope that refinement to the dependency so root imports keep
327                // the broader package mapping.
328                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    /// Returns all remappings declared in foundry.toml files of libraries
433    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            // Load dependency config inputs without recursively installing another remappings
451            // provider. Canonical identity is used only to read each config once; emitted paths
452            // retain the lexical dependency identity.
453            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            // Preserve existing physical nested-config discovery. Symlink discovery is limited to
476            // direct configured-library entries; nested symlink graphs remain out of scope.
477            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            // Custom source directories are not auto-detected. Standard source directories only
491            // need synthesis while missing; when present, package-root autodetection preserves
492            // imports that include the source directory, such as `forge-std/src/...`.
493            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    /// Auto detect remappings from the lib paths
514    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
602/// Narrows an npm scope mapping to its installed packages so missing siblings can use the global
603/// hoisted-package fallback.
604fn 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    // Slash conversion on Windows only preserves a trailing native separator.
716    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        // turn the absolute remapping into a relative one by stripping the `root`
746        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        // First valid remapping
1010        remappings.push(Remapping {
1011            context: None,
1012            name: "MyContract.sol".to_string(),
1013            path: "implementations/Contract1.sol".to_string(),
1014        });
1015
1016        // Same source to different target (should be rejected)
1017        remappings.push(Remapping {
1018            context: None,
1019            name: "MyContract.sol".to_string(),
1020            path: "implementations/Contract2.sol".to_string(),
1021        });
1022
1023        // Different source to same target (should be allowed)
1024        remappings.push(Remapping {
1025            context: None,
1026            name: "OtherContract.sol".to_string(),
1027            path: "implementations/Contract1.sol".to_string(),
1028        });
1029
1030        // Exact duplicate (should be silently ignored)
1031        remappings.push(Remapping {
1032            context: None,
1033            name: "MyContract.sol".to_string(),
1034            path: "implementations/Contract1.sol".to_string(),
1035        });
1036
1037        // Invalid .sol remapping (target not .sol)
1038        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        // Verify the correct remappings exist
1048        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        // Verify the rejected remapping doesn't exist
1064        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        // Same name but different contexts
1108        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}