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 const 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 const 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 src_remapping = 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            Some(r)
283        } else {
284            None
285        };
286
287        // Eventually, we could set context for remappings at this location,
288        // taking into account the OS platform. We'll need to be able to handle nested
289        // contexts depending on dependencies for this to work.
290        // For now, we just leave the default context (none).
291        let mut remappings =
292            config.remappings.into_iter().map(Remapping::from).collect::<Vec<Remapping>>();
293
294        if let Some(r) = src_remapping {
295            remappings.push(r);
296        }
297        remappings
298    }
299
300    /// Auto detect remappings from the lib paths
301    fn auto_detect_remappings(&self) -> impl Iterator<Item = Remapping> + '_ {
302        self.lib_paths
303            .par_iter()
304            .flat_map_iter(|lib| {
305                let lib = self.root.join(lib);
306                trace!(?lib, "find all remappings");
307                Remapping::find_many(&lib)
308            })
309            .collect::<Vec<_>>()
310            .into_iter()
311    }
312}
313
314impl Provider for RemappingsProvider<'_> {
315    fn metadata(&self) -> Metadata {
316        Metadata::named("Remapping Provider")
317    }
318
319    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
320        let remappings = match &self.remappings {
321            Ok(remappings) => self.get_remappings(remappings.clone()),
322            Err(err) => {
323                if let figment::error::Kind::MissingField(_) = err.kind {
324                    self.get_remappings(vec![])
325                } else {
326                    return Err(err.clone());
327                }
328            }
329        }?;
330
331        // turn the absolute remapping into a relative one by stripping the `root`
332        let remappings = remappings
333            .into_iter()
334            .map(|r| RelativeRemapping::new(r, self.root).to_string())
335            .collect::<Vec<_>>();
336
337        Ok(Map::from([(
338            Config::selected_profile(),
339            Dict::from([("remappings".to_string(), figment::value::Value::from(remappings))]),
340        )]))
341    }
342
343    fn profile(&self) -> Option<Profile> {
344        Some(Config::selected_profile())
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_sol_file_remappings() {
354        let mut remappings = Remappings::new();
355
356        // First valid remapping
357        remappings.push(Remapping {
358            context: None,
359            name: "MyContract.sol".to_string(),
360            path: "implementations/Contract1.sol".to_string(),
361        });
362
363        // Same source to different target (should be rejected)
364        remappings.push(Remapping {
365            context: None,
366            name: "MyContract.sol".to_string(),
367            path: "implementations/Contract2.sol".to_string(),
368        });
369
370        // Different source to same target (should be allowed)
371        remappings.push(Remapping {
372            context: None,
373            name: "OtherContract.sol".to_string(),
374            path: "implementations/Contract1.sol".to_string(),
375        });
376
377        // Exact duplicate (should be silently ignored)
378        remappings.push(Remapping {
379            context: None,
380            name: "MyContract.sol".to_string(),
381            path: "implementations/Contract1.sol".to_string(),
382        });
383
384        // Invalid .sol remapping (target not .sol)
385        remappings.push(Remapping {
386            context: None,
387            name: "Invalid.sol".to_string(),
388            path: "implementations/Contract1.txt".to_string(),
389        });
390
391        let result = remappings.into_inner();
392        assert_eq!(result.len(), 2, "Should only have 2 valid remappings");
393
394        // Verify the correct remappings exist
395        assert!(
396            result
397                .iter()
398                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract1.sol"),
399            "Should keep first mapping of MyContract.sol"
400        );
401        assert!(
402            !result
403                .iter()
404                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
405            "Should keep first mapping of MyContract.sol"
406        );
407        assert!(result.iter().any(|r| r.name == "OtherContract.sol" && r.path == "implementations/Contract1.sol"),
408            "Should allow different source to same target");
409
410        // Verify the rejected remapping doesn't exist
411        assert!(
412            !result
413                .iter()
414                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
415            "Should reject same source to different target"
416        );
417    }
418
419    #[test]
420    fn test_mixed_remappings() {
421        let mut remappings = Remappings::new();
422
423        remappings.push(Remapping {
424            context: None,
425            name: "@openzeppelin-contracts/".to_string(),
426            path: "lib/openzeppelin-contracts/".to_string(),
427        });
428        remappings.push(Remapping {
429            context: None,
430            name: "@openzeppelin/contracts/".to_string(),
431            path: "lib/openzeppelin/contracts/".to_string(),
432        });
433
434        remappings.push(Remapping {
435            context: None,
436            name: "MyContract.sol".to_string(),
437            path: "os/Contract.sol".to_string(),
438        });
439
440        let result = remappings.into_inner();
441        assert_eq!(result.len(), 3, "Should have 3 remappings");
442        assert_eq!(result.first().unwrap().name, "@openzeppelin-contracts/");
443        assert_eq!(result.first().unwrap().path, "lib/openzeppelin-contracts/");
444        assert_eq!(result.get(1).unwrap().name, "@openzeppelin/contracts/");
445        assert_eq!(result.get(1).unwrap().path, "lib/openzeppelin/contracts/");
446        assert_eq!(result.get(2).unwrap().name, "MyContract.sol");
447        assert_eq!(result.get(2).unwrap().path, "os/Contract.sol");
448    }
449
450    #[test]
451    fn test_remappings_with_context() {
452        let mut remappings = Remappings::new();
453
454        // Same name but different contexts
455        remappings.push(Remapping {
456            context: Some("test/".to_string()),
457            name: "MyContract.sol".to_string(),
458            path: "test/Contract.sol".to_string(),
459        });
460        remappings.push(Remapping {
461            context: Some("prod/".to_string()),
462            name: "MyContract.sol".to_string(),
463            path: "prod/Contract.sol".to_string(),
464        });
465
466        let result = remappings.into_inner();
467        assert_eq!(result.len(), 2, "Should allow same name with different contexts");
468        assert!(
469            result
470                .iter()
471                .any(|r| r.context == Some("test/".to_string()) && r.path == "test/Contract.sol")
472        );
473        assert!(
474            result
475                .iter()
476                .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol")
477        );
478    }
479}