Skip to main content

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                {
172                    e.insert(path);
173                }
174                Entry::Vacant(e) => {
175                    e.insert(path);
176                }
177                _ => {}
178            }
179        }
180
181        // Let's first just extend the remappings with the ones that were passed in,
182        // without any filtering.
183        let mut user_remappings = Vec::new();
184
185        // check env vars
186        if let Some(env_remappings) = remappings_from_env_var("DAPP_REMAPPINGS")
187            .or_else(|| remappings_from_env_var("FOUNDRY_REMAPPINGS"))
188        {
189            user_remappings
190                .extend(env_remappings.map_err::<Error, _>(|err| err.to_string().into())?);
191        }
192
193        // check remappings.txt file
194        let remappings_file = self.root.join("remappings.txt");
195        if remappings_file.is_file() {
196            let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
197            let remappings_from_file: Result<Vec<_>, _> =
198                remappings_from_newline(&content).collect();
199            user_remappings
200                .extend(remappings_from_file.map_err::<Error, _>(|err| err.to_string().into())?);
201        }
202
203        user_remappings.extend(remappings);
204        // Let's now use the wrapper to conditionally extend the remappings with the autodetected
205        // ones. We want to avoid duplicates, and the wrapper will handle this for us.
206        let mut all_remappings = Remappings::new_with_remappings(user_remappings);
207
208        // scan all library dirs and autodetect remappings
209        // TODO: if a lib specifies contexts for remappings manually, we need to figure out how to
210        // resolve that
211        if self.auto_detect_remappings {
212            let (nested_foundry_remappings, auto_detected_remappings) = rayon::join(
213                || self.find_nested_foundry_remappings(),
214                || self.auto_detect_remappings(),
215            );
216
217            let mut lib_remappings = BTreeMap::new();
218            for r in nested_foundry_remappings {
219                insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
220            }
221            for r in auto_detected_remappings {
222                // this is an additional safety check for weird auto-detected remappings
223                if ["lib/", "src/", "contracts/"].contains(&r.name.as_str()) {
224                    trace!(target: "forge", "- skipping the remapping");
225                    continue;
226                }
227                insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
228            }
229
230            all_remappings.extend(
231                lib_remappings
232                    .into_iter()
233                    .flat_map(|(context, remappings)| {
234                        remappings.into_iter().map(move |(name, path)| Remapping {
235                            context: context.clone(),
236                            name,
237                            path: path.to_string_lossy().into(),
238                        })
239                    })
240                    .collect(),
241            );
242        }
243
244        Ok(all_remappings.into_inner())
245    }
246
247    /// Returns all remappings declared in foundry.toml files of libraries
248    fn find_nested_foundry_remappings(&self) -> impl Iterator<Item = Remapping> + '_ {
249        self.lib_paths
250            .par_iter()
251            .map(|p| if p.is_absolute() { self.root.join("lib") } else { self.root.join(p) })
252            .flat_map(foundry_toml_dirs)
253            .flat_map_iter(|lib| {
254                trace!(?lib, "find all remappings of nested foundry.toml");
255                self.nested_foundry_remappings(&lib)
256            })
257            .collect::<Vec<_>>()
258            .into_iter()
259    }
260
261    fn nested_foundry_remappings(&self, lib: &Path) -> Vec<Remapping> {
262        // load config of the nested lib if it exists, using fallback mode since libs may not
263        // define all profiles the main project uses
264        let Ok(config) = Config::load_with_root_and_fallback(lib) else { return vec![] };
265        let config = config.sanitized();
266
267        // if the configured _src_ directory is set to something that
268        // `Remapping::find_many` doesn't classify as a src directory (src, contracts,
269        // lib), then we need to manually add a remapping here
270        let mut src_remapping = None;
271        if ![Path::new("src"), Path::new("contracts"), Path::new("lib")]
272            .contains(&config.src.as_path())
273            && let Some(name) = lib.file_name().and_then(|s| s.to_str())
274        {
275            let mut r = Remapping {
276                context: None,
277                name: format!("{name}/"),
278                path: format!("{}", lib.join(&config.src).display()),
279            };
280            if !r.path.ends_with('/') {
281                r.path.push('/')
282            }
283            src_remapping = Some(r);
284        }
285
286        // Eventually, we could set context for remappings at this location,
287        // taking into account the OS platform. We'll need to be able to handle nested
288        // contexts depending on dependencies for this to work.
289        // For now, we just leave the default context (none).
290        let mut remappings =
291            config.remappings.into_iter().map(Remapping::from).collect::<Vec<Remapping>>();
292
293        if let Some(r) = src_remapping {
294            remappings.push(r);
295        }
296        remappings
297    }
298
299    /// Auto detect remappings from the lib paths
300    fn auto_detect_remappings(&self) -> impl Iterator<Item = Remapping> + '_ {
301        self.lib_paths
302            .par_iter()
303            .flat_map_iter(|lib| {
304                let lib = self.root.join(lib);
305                trace!(?lib, "find all remappings");
306                Remapping::find_many(&lib)
307            })
308            .collect::<Vec<_>>()
309            .into_iter()
310    }
311}
312
313impl Provider for RemappingsProvider<'_> {
314    fn metadata(&self) -> Metadata {
315        Metadata::named("Remapping Provider")
316    }
317
318    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
319        let remappings = match &self.remappings {
320            Ok(remappings) => self.get_remappings(remappings.clone()),
321            Err(err) => {
322                if let figment::error::Kind::MissingField(_) = err.kind {
323                    self.get_remappings(vec![])
324                } else {
325                    return Err(err.clone());
326                }
327            }
328        }?;
329
330        // turn the absolute remapping into a relative one by stripping the `root`
331        let remappings = remappings
332            .into_iter()
333            .map(|r| RelativeRemapping::new(r, self.root).to_string())
334            .collect::<Vec<_>>();
335
336        Ok(Map::from([(
337            Config::selected_profile(),
338            Dict::from([("remappings".to_string(), figment::value::Value::from(remappings))]),
339        )]))
340    }
341
342    fn profile(&self) -> Option<Profile> {
343        Some(Config::selected_profile())
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_sol_file_remappings() {
353        let mut remappings = Remappings::new();
354
355        // First valid remapping
356        remappings.push(Remapping {
357            context: None,
358            name: "MyContract.sol".to_string(),
359            path: "implementations/Contract1.sol".to_string(),
360        });
361
362        // Same source to different target (should be rejected)
363        remappings.push(Remapping {
364            context: None,
365            name: "MyContract.sol".to_string(),
366            path: "implementations/Contract2.sol".to_string(),
367        });
368
369        // Different source to same target (should be allowed)
370        remappings.push(Remapping {
371            context: None,
372            name: "OtherContract.sol".to_string(),
373            path: "implementations/Contract1.sol".to_string(),
374        });
375
376        // Exact duplicate (should be silently ignored)
377        remappings.push(Remapping {
378            context: None,
379            name: "MyContract.sol".to_string(),
380            path: "implementations/Contract1.sol".to_string(),
381        });
382
383        // Invalid .sol remapping (target not .sol)
384        remappings.push(Remapping {
385            context: None,
386            name: "Invalid.sol".to_string(),
387            path: "implementations/Contract1.txt".to_string(),
388        });
389
390        let result = remappings.into_inner();
391        assert_eq!(result.len(), 2, "Should only have 2 valid remappings");
392
393        // Verify the correct remappings exist
394        assert!(
395            result
396                .iter()
397                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract1.sol"),
398            "Should keep first mapping of MyContract.sol"
399        );
400        assert!(
401            !result
402                .iter()
403                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
404            "Should keep first mapping of MyContract.sol"
405        );
406        assert!(result.iter().any(|r| r.name == "OtherContract.sol" && r.path == "implementations/Contract1.sol"),
407            "Should allow different source to same target");
408
409        // Verify the rejected remapping doesn't exist
410        assert!(
411            !result
412                .iter()
413                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
414            "Should reject same source to different target"
415        );
416    }
417
418    #[test]
419    fn test_mixed_remappings() {
420        let mut remappings = Remappings::new();
421
422        remappings.push(Remapping {
423            context: None,
424            name: "@openzeppelin-contracts/".to_string(),
425            path: "lib/openzeppelin-contracts/".to_string(),
426        });
427        remappings.push(Remapping {
428            context: None,
429            name: "@openzeppelin/contracts/".to_string(),
430            path: "lib/openzeppelin/contracts/".to_string(),
431        });
432
433        remappings.push(Remapping {
434            context: None,
435            name: "MyContract.sol".to_string(),
436            path: "os/Contract.sol".to_string(),
437        });
438
439        let result = remappings.into_inner();
440        assert_eq!(result.len(), 3, "Should have 3 remappings");
441        assert_eq!(result.first().unwrap().name, "@openzeppelin-contracts/");
442        assert_eq!(result.first().unwrap().path, "lib/openzeppelin-contracts/");
443        assert_eq!(result.get(1).unwrap().name, "@openzeppelin/contracts/");
444        assert_eq!(result.get(1).unwrap().path, "lib/openzeppelin/contracts/");
445        assert_eq!(result.get(2).unwrap().name, "MyContract.sol");
446        assert_eq!(result.get(2).unwrap().path, "os/Contract.sol");
447    }
448
449    #[test]
450    fn test_remappings_with_context() {
451        let mut remappings = Remappings::new();
452
453        // Same name but different contexts
454        remappings.push(Remapping {
455            context: Some("test/".to_string()),
456            name: "MyContract.sol".to_string(),
457            path: "test/Contract.sol".to_string(),
458        });
459        remappings.push(Remapping {
460            context: Some("prod/".to_string()),
461            name: "MyContract.sol".to_string(),
462            path: "prod/Contract.sol".to_string(),
463        });
464
465        let result = remappings.into_inner();
466        assert_eq!(result.len(), 2, "Should allow same name with different contexts");
467        assert!(
468            result
469                .iter()
470                .any(|r| r.context == Some("test/".to_string()) && r.path == "test/Contract.sol")
471        );
472        assert!(
473            result
474                .iter()
475                .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol")
476        );
477    }
478}