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