foundry_config/providers/
remappings.rs

1use crate::{Config, foundry_toml_dirs, remappings_from_env_var, remappings_from_newline};
2use figment::{
3    Error, Figment, Metadata, Profile, Provider,
4    value::{Dict, Map},
5};
6use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping};
7use rayon::prelude::*;
8use std::{
9    borrow::Cow,
10    collections::{BTreeMap, HashSet, btree_map::Entry},
11    fs,
12    path::{Path, PathBuf},
13};
14
15/// Wrapper types over a `Vec<Remapping>` that only appends unique remappings.
16#[derive(Clone, Debug, Default)]
17pub struct Remappings {
18    /// Remappings.
19    remappings: Vec<Remapping>,
20    /// Source, test and script configured project dirs.
21    /// Remappings of these dirs from libs are ignored.
22    project_paths: Vec<Remapping>,
23}
24
25impl Remappings {
26    /// Create a new `Remappings` wrapper with an empty vector.
27    pub fn new() -> Self {
28        Self { remappings: Vec::new(), project_paths: Vec::new() }
29    }
30
31    /// Create a new `Remappings` wrapper with a vector of remappings.
32    pub fn new_with_remappings(remappings: Vec<Remapping>) -> Self {
33        Self { remappings, project_paths: Vec::new() }
34    }
35
36    /// Extract project paths that cannot be remapped by dependencies.
37    pub fn with_figment(mut self, figment: &Figment) -> Self {
38        let mut add_project_remapping = |path: &str| {
39            if let Ok(path) = figment.find_value(path)
40                && let Some(path) = path.into_string()
41            {
42                let remapping =
43                    Remapping { context: None, name: format!("{path}/"), path: format!("{path}/") };
44                self.project_paths.push(remapping);
45            }
46        };
47        add_project_remapping("src");
48        add_project_remapping("test");
49        add_project_remapping("script");
50        self
51    }
52
53    /// Filters the remappings vector by name and context.
54    fn filter_key(r: &Remapping) -> String {
55        match &r.context {
56            Some(str) => str.clone() + &r.name.clone(),
57            None => r.name.clone(),
58        }
59    }
60
61    /// Consumes the wrapper and returns the inner remappings vector.
62    pub fn into_inner(self) -> Vec<Remapping> {
63        let mut seen = HashSet::new();
64        self.remappings.iter().filter(|r| seen.insert(Self::filter_key(r))).cloned().collect()
65    }
66
67    /// Push an element to the remappings vector, but only if it's not already present.
68    fn push(&mut self, remapping: Remapping) {
69        // Special handling for .sol file remappings, only allow one remapping per source file.
70        if remapping.name.ends_with(".sol") && !remapping.path.ends_with(".sol") {
71            return;
72        }
73
74        if self.remappings.iter().any(|existing| {
75            if remapping.name.ends_with(".sol") {
76                // For .sol files, only prevent duplicate source names in the same context
77                return existing.name == remapping.name
78                    && existing.context == remapping.context
79                    && existing.path == remapping.path;
80            }
81
82            // What we're doing here is filtering for ambiguous paths. For example, if we have
83            // @prb/math/=node_modules/@prb/math/src/ as existing, and
84            // @prb/=node_modules/@prb/ as the one being checked,
85            // we want to keep the already existing one, which is the first one. This way we avoid
86            // having to deal with ambiguous paths which is unwanted when autodetecting remappings.
87            // Remappings are added from root of the project down to libraries, so
88            // we also want to exclude any conflicting remappings added from libraries. For example,
89            // if we have `@utils/=src/` added in project remappings and `@utils/libraries/=src/`
90            // added in a dependency, we don't want to add the new one as it conflicts with project
91            // existing remapping.
92            let mut existing_name_path = existing.name.clone();
93            if !existing_name_path.ends_with('/') {
94                existing_name_path.push('/')
95            }
96            let is_conflicting = remapping.name.starts_with(&existing_name_path)
97                || existing.name.starts_with(&remapping.name);
98            is_conflicting && existing.context == remapping.context
99        }) {
100            return;
101        };
102
103        // Ignore remappings of root project src, test or script dir.
104        // See <https://github.com/foundry-rs/foundry/issues/3440>.
105        if self
106            .project_paths
107            .iter()
108            .any(|project_path| remapping.name.eq_ignore_ascii_case(&project_path.name))
109        {
110            return;
111        };
112
113        self.remappings.push(remapping);
114    }
115
116    /// Extend the remappings vector, leaving out the remappings that are already present.
117    pub fn extend(&mut self, remappings: Vec<Remapping>) {
118        for remapping in remappings {
119            self.push(remapping);
120        }
121    }
122}
123
124/// A figment provider that checks if the remappings were previously set and if they're unset looks
125/// up the fs via
126///   - `DAPP_REMAPPINGS` || `FOUNDRY_REMAPPINGS` env var
127///   - `<root>/remappings.txt` file
128///   - `Remapping::find_many`.
129pub struct RemappingsProvider<'a> {
130    /// Whether to auto detect remappings from the `lib_paths`
131    pub auto_detect_remappings: bool,
132    /// The lib/dependency directories to scan for remappings
133    pub lib_paths: Cow<'a, Vec<PathBuf>>,
134    /// the root path used to turn an absolute `Remapping`, as we're getting it from
135    /// `Remapping::find_many` into a relative one.
136    pub root: &'a Path,
137    /// This contains either:
138    ///   - previously set remappings
139    ///   - a `MissingField` error, which means previous provider didn't set the "remappings" field
140    ///   - other error, like formatting
141    pub remappings: Result<Vec<Remapping>, Error>,
142}
143
144impl RemappingsProvider<'_> {
145    /// Find and parse remappings for the projects
146    ///
147    /// **Order**
148    ///
149    /// Remappings are built in this order (last item takes precedence)
150    /// - Autogenerated remappings
151    /// - toml remappings
152    /// - `remappings.txt`
153    /// - Environment variables
154    /// - CLI parameters
155    fn get_remappings(&self, remappings: Vec<Remapping>) -> Result<Vec<Remapping>, Error> {
156        trace!("get all remappings from {:?}", self.root);
157        /// prioritizes remappings that are closer: shorter `path`
158        ///   - ("a", "1/2") over ("a", "1/2/3")
159        ///
160        /// grouped by remapping context
161        fn insert_closest(
162            mappings: &mut BTreeMap<Option<String>, BTreeMap<String, PathBuf>>,
163            context: Option<String>,
164            key: String,
165            path: PathBuf,
166        ) {
167            let context_mappings = mappings.entry(context).or_default();
168            match context_mappings.entry(key) {
169                Entry::Occupied(mut e) => {
170                    if e.get().components().count() > path.components().count() {
171                        e.insert(path);
172                    }
173                }
174                Entry::Vacant(e) => {
175                    e.insert(path);
176                }
177            }
178        }
179
180        // Let's first just extend the remappings with the ones that were passed in,
181        // without any filtering.
182        let mut user_remappings = Vec::new();
183
184        // check env vars
185        if let Some(env_remappings) = remappings_from_env_var("DAPP_REMAPPINGS")
186            .or_else(|| remappings_from_env_var("FOUNDRY_REMAPPINGS"))
187        {
188            user_remappings
189                .extend(env_remappings.map_err::<Error, _>(|err| err.to_string().into())?);
190        }
191
192        // check remappings.txt file
193        let remappings_file = self.root.join("remappings.txt");
194        if remappings_file.is_file() {
195            let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
196            let remappings_from_file: Result<Vec<_>, _> =
197                remappings_from_newline(&content).collect();
198            user_remappings
199                .extend(remappings_from_file.map_err::<Error, _>(|err| err.to_string().into())?);
200        }
201
202        user_remappings.extend(remappings);
203        // Let's now use the wrapper to conditionally extend the remappings with the autodetected
204        // ones. We want to avoid duplicates, and the wrapper will handle this for us.
205        let mut all_remappings = Remappings::new_with_remappings(user_remappings);
206
207        // scan all library dirs and autodetect remappings
208        // TODO: if a lib specifies contexts for remappings manually, we need to figure out how to
209        // resolve that
210        if self.auto_detect_remappings {
211            let (nested_foundry_remappings, auto_detected_remappings) = rayon::join(
212                || self.find_nested_foundry_remappings(),
213                || self.auto_detect_remappings(),
214            );
215
216            let mut lib_remappings = BTreeMap::new();
217            for r in nested_foundry_remappings {
218                insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
219            }
220            for r in auto_detected_remappings {
221                // this is an additional safety check for weird auto-detected remappings
222                if ["lib/", "src/", "contracts/"].contains(&r.name.as_str()) {
223                    trace!(target: "forge", "- skipping the remapping");
224                    continue;
225                }
226                insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
227            }
228
229            all_remappings.extend(
230                lib_remappings
231                    .into_iter()
232                    .flat_map(|(context, remappings)| {
233                        remappings.into_iter().map(move |(name, path)| Remapping {
234                            context: context.clone(),
235                            name,
236                            path: path.to_string_lossy().into(),
237                        })
238                    })
239                    .collect(),
240            );
241        }
242
243        Ok(all_remappings.into_inner())
244    }
245
246    /// Returns all remappings declared in foundry.toml files of libraries
247    fn find_nested_foundry_remappings(&self) -> impl Iterator<Item = Remapping> + '_ {
248        self.lib_paths
249            .par_iter()
250            .map(|p| if p.is_absolute() { self.root.join("lib") } else { self.root.join(p) })
251            .flat_map(foundry_toml_dirs)
252            .flat_map_iter(|lib| {
253                trace!(?lib, "find all remappings of nested foundry.toml");
254                self.nested_foundry_remappings(&lib)
255            })
256            .collect::<Vec<_>>()
257            .into_iter()
258    }
259
260    fn nested_foundry_remappings(&self, lib: &Path) -> Vec<Remapping> {
261        // load config of the nested lib if it exists, using fallback mode since libs may not
262        // define all profiles the main project uses
263        let Ok(config) = Config::load_with_root_and_fallback(lib) else { return vec![] };
264        let config = config.sanitized();
265
266        // if the configured _src_ directory is set to something that
267        // `Remapping::find_many` doesn't classify as a src directory (src, contracts,
268        // lib), then we need to manually add a remapping here
269        let mut src_remapping = None;
270        if ![Path::new("src"), Path::new("contracts"), Path::new("lib")]
271            .contains(&config.src.as_path())
272            && let Some(name) = lib.file_name().and_then(|s| s.to_str())
273        {
274            let mut r = Remapping {
275                context: None,
276                name: format!("{name}/"),
277                path: format!("{}", lib.join(&config.src).display()),
278            };
279            if !r.path.ends_with('/') {
280                r.path.push('/')
281            }
282            src_remapping = Some(r);
283        }
284
285        // Eventually, we could set context for remappings at this location,
286        // taking into account the OS platform. We'll need to be able to handle nested
287        // contexts depending on dependencies for this to work.
288        // For now, we just leave the default context (none).
289        let mut remappings =
290            config.remappings.into_iter().map(Remapping::from).collect::<Vec<Remapping>>();
291
292        if let Some(r) = src_remapping {
293            remappings.push(r);
294        }
295        remappings
296    }
297
298    /// Auto detect remappings from the lib paths
299    fn auto_detect_remappings(&self) -> impl Iterator<Item = Remapping> + '_ {
300        self.lib_paths
301            .par_iter()
302            .flat_map_iter(|lib| {
303                let lib = self.root.join(lib);
304                trace!(?lib, "find all remappings");
305                Remapping::find_many(&lib)
306            })
307            .collect::<Vec<_>>()
308            .into_iter()
309    }
310}
311
312impl Provider for RemappingsProvider<'_> {
313    fn metadata(&self) -> Metadata {
314        Metadata::named("Remapping Provider")
315    }
316
317    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
318        let remappings = match &self.remappings {
319            Ok(remappings) => self.get_remappings(remappings.clone()),
320            Err(err) => {
321                if let figment::error::Kind::MissingField(_) = err.kind {
322                    self.get_remappings(vec![])
323                } else {
324                    return Err(err.clone());
325                }
326            }
327        }?;
328
329        // turn the absolute remapping into a relative one by stripping the `root`
330        let remappings = remappings
331            .into_iter()
332            .map(|r| RelativeRemapping::new(r, self.root).to_string())
333            .collect::<Vec<_>>();
334
335        Ok(Map::from([(
336            Config::selected_profile(),
337            Dict::from([("remappings".to_string(), figment::value::Value::from(remappings))]),
338        )]))
339    }
340
341    fn profile(&self) -> Option<Profile> {
342        Some(Config::selected_profile())
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_sol_file_remappings() {
352        let mut remappings = Remappings::new();
353
354        // First valid remapping
355        remappings.push(Remapping {
356            context: None,
357            name: "MyContract.sol".to_string(),
358            path: "implementations/Contract1.sol".to_string(),
359        });
360
361        // Same source to different target (should be rejected)
362        remappings.push(Remapping {
363            context: None,
364            name: "MyContract.sol".to_string(),
365            path: "implementations/Contract2.sol".to_string(),
366        });
367
368        // Different source to same target (should be allowed)
369        remappings.push(Remapping {
370            context: None,
371            name: "OtherContract.sol".to_string(),
372            path: "implementations/Contract1.sol".to_string(),
373        });
374
375        // Exact duplicate (should be silently ignored)
376        remappings.push(Remapping {
377            context: None,
378            name: "MyContract.sol".to_string(),
379            path: "implementations/Contract1.sol".to_string(),
380        });
381
382        // Invalid .sol remapping (target not .sol)
383        remappings.push(Remapping {
384            context: None,
385            name: "Invalid.sol".to_string(),
386            path: "implementations/Contract1.txt".to_string(),
387        });
388
389        let result = remappings.into_inner();
390        assert_eq!(result.len(), 2, "Should only have 2 valid remappings");
391
392        // Verify the correct remappings exist
393        assert!(
394            result
395                .iter()
396                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract1.sol"),
397            "Should keep first mapping of MyContract.sol"
398        );
399        assert!(
400            !result
401                .iter()
402                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
403            "Should keep first mapping of MyContract.sol"
404        );
405        assert!(result.iter().any(|r| r.name == "OtherContract.sol" && r.path == "implementations/Contract1.sol"),
406            "Should allow different source to same target");
407
408        // Verify the rejected remapping doesn't exist
409        assert!(
410            !result
411                .iter()
412                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
413            "Should reject same source to different target"
414        );
415    }
416
417    #[test]
418    fn test_mixed_remappings() {
419        let mut remappings = Remappings::new();
420
421        remappings.push(Remapping {
422            context: None,
423            name: "@openzeppelin-contracts/".to_string(),
424            path: "lib/openzeppelin-contracts/".to_string(),
425        });
426        remappings.push(Remapping {
427            context: None,
428            name: "@openzeppelin/contracts/".to_string(),
429            path: "lib/openzeppelin/contracts/".to_string(),
430        });
431
432        remappings.push(Remapping {
433            context: None,
434            name: "MyContract.sol".to_string(),
435            path: "os/Contract.sol".to_string(),
436        });
437
438        let result = remappings.into_inner();
439        assert_eq!(result.len(), 3, "Should have 3 remappings");
440        assert_eq!(result.first().unwrap().name, "@openzeppelin-contracts/");
441        assert_eq!(result.first().unwrap().path, "lib/openzeppelin-contracts/");
442        assert_eq!(result.get(1).unwrap().name, "@openzeppelin/contracts/");
443        assert_eq!(result.get(1).unwrap().path, "lib/openzeppelin/contracts/");
444        assert_eq!(result.get(2).unwrap().name, "MyContract.sol");
445        assert_eq!(result.get(2).unwrap().path, "os/Contract.sol");
446    }
447
448    #[test]
449    fn test_remappings_with_context() {
450        let mut remappings = Remappings::new();
451
452        // Same name but different contexts
453        remappings.push(Remapping {
454            context: Some("test/".to_string()),
455            name: "MyContract.sol".to_string(),
456            path: "test/Contract.sol".to_string(),
457        });
458        remappings.push(Remapping {
459            context: Some("prod/".to_string()),
460            name: "MyContract.sol".to_string(),
461            path: "prod/Contract.sol".to_string(),
462        });
463
464        let result = remappings.into_inner();
465        assert_eq!(result.len(), 2, "Should allow same name with different contexts");
466        assert!(
467            result
468                .iter()
469                .any(|r| r.context == Some("test/".to_string()) && r.path == "test/Contract.sol")
470        );
471        assert!(
472            result
473                .iter()
474                .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol")
475        );
476    }
477}