solar_interface/source_map/
file_resolver.rs

1//! File resolver.
2//!
3//! Modified from [`solang`](https://github.com/hyperledger/solang/blob/0f032dcec2c6e96797fd66fa0175a02be0aba71c/src/file_resolver.rs).
4
5use super::SourceFile;
6use crate::{Session, SourceMap};
7use itertools::Itertools;
8use normalize_path::NormalizePath;
9use solar_config::ImportRemapping;
10use solar_data_structures::smallvec::SmallVec;
11use std::{
12    borrow::Cow,
13    io,
14    path::{Path, PathBuf},
15    sync::{Arc, OnceLock},
16};
17
18/// An error that occurred while resolving a path.
19#[derive(Debug, thiserror::Error)]
20pub enum ResolveError {
21    #[error("couldn't read stdin: {0}")]
22    ReadStdin(#[source] io::Error),
23    #[error("couldn't read {0}: {1}")]
24    ReadFile(PathBuf, #[source] io::Error),
25    #[error("file {0} not found")]
26    NotFound(PathBuf),
27    #[error("multiple files match {}: {}", .0.display(), .1.iter().map(|f| f.name.display()).format(", "))]
28    MultipleMatches(PathBuf, Vec<Arc<SourceFile>>),
29}
30
31/// Performs file resolution by applying import paths and mappings.
32#[derive(derive_more::Debug)]
33pub struct FileResolver<'a> {
34    #[debug(skip)]
35    source_map: &'a SourceMap,
36
37    /// Include paths.
38    include_paths: Vec<PathBuf>,
39    /// Import remappings.
40    remappings: Vec<ImportRemapping>,
41
42    /// Custom current directory.
43    custom_current_dir: Option<PathBuf>,
44    /// [`std::env::current_dir`] cache. Unused if the current directory is set manually.
45    env_current_dir: OnceLock<Option<PathBuf>>,
46}
47
48impl<'a> FileResolver<'a> {
49    /// Creates a new file resolver.
50    pub fn new(source_map: &'a SourceMap) -> Self {
51        Self {
52            source_map,
53            include_paths: Vec::new(),
54            remappings: Vec::new(),
55            custom_current_dir: None,
56            env_current_dir: OnceLock::new(),
57        }
58    }
59
60    /// Configures the file resolver from a session.
61    pub fn configure_from_sess(&mut self, sess: &Session) {
62        self.add_include_paths(sess.opts.include_paths.iter().cloned());
63        self.add_import_remappings(sess.opts.import_remappings.iter().cloned());
64        'b: {
65            if let Some(base_path) = &sess.opts.base_path {
66                let base_path = if base_path.is_absolute() {
67                    base_path.as_path()
68                } else {
69                    &if let Ok(path) = self.canonicalize_unchecked(base_path) {
70                        path
71                    } else {
72                        break 'b;
73                    }
74                };
75                self.set_current_dir(base_path);
76            }
77        }
78    }
79
80    /// Clears the internal state.
81    pub fn clear(&mut self) {
82        self.include_paths.clear();
83        self.remappings.clear();
84        self.custom_current_dir = None;
85        self.env_current_dir.take();
86    }
87
88    /// Sets the current directory.
89    ///
90    /// # Panics
91    ///
92    /// Panics if `current_dir` is not an absolute path.
93    #[track_caller]
94    #[doc(alias = "set_base_path")]
95    pub fn set_current_dir(&mut self, current_dir: &Path) {
96        if !current_dir.is_absolute() {
97            panic!("current_dir must be an absolute path");
98        }
99        self.custom_current_dir = Some(current_dir.to_path_buf());
100    }
101
102    /// Adds include paths.
103    pub fn add_include_paths(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
104        self.include_paths.extend(paths);
105    }
106
107    /// Adds an include path.
108    pub fn add_include_path(&mut self, path: PathBuf) {
109        self.include_paths.push(path)
110    }
111
112    /// Adds import remappings.
113    pub fn add_import_remappings(&mut self, remappings: impl IntoIterator<Item = ImportRemapping>) {
114        self.remappings.extend(remappings);
115    }
116
117    /// Adds an import remapping.
118    pub fn add_import_remapping(&mut self, remapping: ImportRemapping) {
119        self.remappings.push(remapping);
120    }
121
122    /// Returns the source map.
123    pub fn source_map(&self) -> &'a SourceMap {
124        self.source_map
125    }
126
127    /// Returns the current directory, or `.` if it could not be resolved.
128    #[doc(alias = "base_path")]
129    pub fn current_dir(&self) -> &Path {
130        self.try_current_dir().unwrap_or(Path::new("."))
131    }
132
133    /// Returns the current directory, if resolved successfully.
134    #[doc(alias = "try_base_path")]
135    pub fn try_current_dir(&self) -> Option<&Path> {
136        self.custom_current_dir.as_deref().or_else(|| self.env_current_dir())
137    }
138
139    fn env_current_dir(&self) -> Option<&Path> {
140        self.env_current_dir
141            .get_or_init(|| {
142                std::env::current_dir()
143                    .inspect_err(|e| debug!("failed to get current_dir: {e}"))
144                    .ok()
145            })
146            .as_deref()
147    }
148
149    /// Canonicalizes a path using [`Self::current_dir`].
150    pub fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
151        self.canonicalize_unchecked(&self.make_absolute(path))
152    }
153
154    fn canonicalize_unchecked(&self, path: &Path) -> io::Result<PathBuf> {
155        self.source_map.file_loader().canonicalize_path(path)
156    }
157
158    /// Normalizes a path removing unnecessary components.
159    ///
160    /// Does not perform I/O.
161    pub fn normalize<'b>(&self, path: &'b Path) -> Cow<'b, Path> {
162        // NOTE: checking `is_normalized` will not produce the correct result since it won't
163        // consider `./` segments. See its documentation.
164        Cow::Owned(path.normalize())
165    }
166
167    /// Makes the path absolute by joining it with the current directory.
168    ///
169    /// Does not perform I/O.
170    pub fn make_absolute<'b>(&self, path: &'b Path) -> Cow<'b, Path> {
171        if path.is_absolute() {
172            Cow::Borrowed(path)
173        } else if let Some(current_dir) = self.try_current_dir() {
174            Cow::Owned(current_dir.join(path))
175        } else {
176            Cow::Borrowed(path)
177        }
178    }
179
180    /// Resolves an import path.
181    ///
182    /// `parent` is the path of the file that contains the import, if any.
183    #[instrument(level = "debug", skip_all, fields(path = %path.display()))]
184    pub fn resolve_file(
185        &self,
186        path: &Path,
187        parent: Option<&Path>,
188    ) -> Result<Arc<SourceFile>, ResolveError> {
189        // https://docs.soliditylang.org/en/latest/path-resolution.html
190        // Only when the path starts with ./ or ../ are relative paths considered; this means
191        // that `import "b.sol";` will check the import paths for b.sol, while `import "./b.sol";`
192        // will only check the path relative to the current file.
193        //
194        // `parent.is_none()` only happens when resolving imports from a custom/stdin file, or when
195        // manually resolving a file, like from CLI arguments. In these cases, the file is
196        // considered to be in the current directory.
197        // Technically, this behavior allows the latter, the manual case, to also be resolved using
198        // remappings, which is not the case in solc, but this simplifies the implementation.
199        let is_relative = path.starts_with("./") || path.starts_with("../");
200        if (is_relative && parent.is_some()) || parent.is_none() {
201            let try_path = if let Some(base) = parent.filter(|_| is_relative).and_then(Path::parent)
202            {
203                &base.join(path)
204            } else {
205                path
206            };
207            if let Some(file) = self.try_file(try_path)? {
208                return Ok(file);
209            }
210            // See above.
211            if is_relative {
212                return Err(ResolveError::NotFound(path.into()));
213            }
214        }
215
216        let original_path = path;
217        let path = &*self.remap_path(path, parent);
218
219        let mut candidates = SmallVec::<[_; 1]>::new();
220        // Quick deduplication when include paths are duplicated.
221        let mut push_candidate = |file: Arc<SourceFile>| {
222            if !candidates.iter().any(|f| Arc::ptr_eq(f, &file)) {
223                candidates.push(file);
224            }
225        };
226
227        // If there are no include paths, then try the file directly. See
228        // https://docs.soliditylang.org/en/latest/path-resolution.html#base-path-and-include-paths
229        // "By default the base path is empty, which leaves the source unit name unchanged."
230        if self.include_paths.is_empty() || path.is_absolute() {
231            if let Some(file) = self.try_file(path)? {
232                push_candidate(file);
233            }
234        } else {
235            // Try all the include paths.
236            let base_path = self.try_current_dir().into_iter();
237            for include_path in base_path.chain(self.include_paths.iter().map(|p| p.as_path())) {
238                let path = include_path.join(path);
239                if let Some(file) = self.try_file(&path)? {
240                    push_candidate(file);
241                }
242            }
243        }
244
245        match candidates.len() {
246            0 => Err(ResolveError::NotFound(original_path.into())),
247            1 => Ok(candidates.pop().unwrap()),
248            _ => Err(ResolveError::MultipleMatches(original_path.into(), candidates.into_vec())),
249        }
250    }
251
252    /// Applies the import path mappings to `path`.
253    // Reference: <https://github.com/argotorg/solidity/blob/e202d30db8e7e4211ee973237ecbe485048aae97/libsolidity/interface/ImportRemapper.cpp#L32>
254    pub fn remap_path<'b>(&self, path: &'b Path, parent: Option<&Path>) -> Cow<'b, Path> {
255        let remapped = self.remap_path_(path, parent);
256        if remapped != path {
257            trace!(remapped=%remapped.display());
258        }
259        remapped
260    }
261
262    fn remap_path_<'b>(&self, path: &'b Path, parent: Option<&Path>) -> Cow<'b, Path> {
263        let _context = &*parent.map(|p| p.to_string_lossy()).unwrap_or_default();
264
265        let mut longest_prefix = 0;
266        let mut longest_context = 0;
267        let mut best_match_target = None;
268        let mut unprefixed_path = path;
269        for ImportRemapping { context, prefix, path: target } in &self.remappings {
270            let context = &*sanitize_path(context);
271            let prefix = &*sanitize_path(prefix);
272
273            if context.len() < longest_context {
274                continue;
275            }
276            if !_context.starts_with(context) {
277                continue;
278            }
279            if prefix.len() < longest_prefix {
280                continue;
281            }
282            let Ok(up) = path.strip_prefix(prefix) else {
283                continue;
284            };
285            longest_context = context.len();
286            longest_prefix = prefix.len();
287            best_match_target = Some(sanitize_path(target));
288            unprefixed_path = up;
289        }
290        if let Some(best_match_target) = best_match_target {
291            let mut out = PathBuf::from(&*best_match_target);
292            out.push(unprefixed_path);
293            Cow::Owned(out)
294        } else {
295            Cow::Borrowed(unprefixed_path)
296        }
297    }
298
299    /// Loads stdin into the source map.
300    pub fn load_stdin(&self) -> Result<Arc<SourceFile>, ResolveError> {
301        self.source_map().load_stdin().map_err(ResolveError::ReadStdin)
302    }
303
304    /// Loads `path` into the source map. Returns `None` if the file doesn't exist.
305    #[instrument(level = "debug", skip_all, fields(path = %path.display()))]
306    pub fn try_file(&self, path: &Path) -> Result<Option<Arc<SourceFile>>, ResolveError> {
307        // Normalize unnecessary components.
308        let rpath = &*self.normalize(path);
309        if let Some(file) = self.source_map().get_file(rpath) {
310            trace!("loaded from cache 1");
311            return Ok(Some(file));
312        }
313
314        // Make the path absolute with the current directory.
315        let apath = &*self.make_absolute(rpath);
316        if apath != rpath
317            && let Some(file) = self.source_map().get_file(apath)
318        {
319            trace!("loaded from cache 2");
320            return Ok(Some(file));
321        }
322
323        // Canonicalize, checking symlinks and if it exists.
324        if let Ok(path) = self.canonicalize_unchecked(apath) {
325            return self
326                .source_map()
327                // Store the file with `apath` as the name instead of `path`.
328                // In case of symlinks we want to reference the symlink path, not the target path.
329                .load_file_with_name(apath.to_path_buf().into(), &path)
330                .map(Some)
331                .map_err(|e| ResolveError::ReadFile(path, e));
332        }
333
334        trace!("not found");
335        Ok(None)
336    }
337}
338
339fn sanitize_path(s: &str) -> impl std::ops::Deref<Target = str> + '_ {
340    // TODO: Equivalent of: `boost::filesystem::path(_path).generic_string()`
341    s
342}