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