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