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