foundry_config/providers/
remappings.rs

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