Skip to main content

foundry_config/
filter.rs

1//! Helpers for constructing and using [FileFilter]s.
2
3use core::fmt;
4use foundry_compilers::FileFilter;
5use serde::{Deserialize, Serialize};
6use std::{
7    convert::Infallible,
8    path::{Path, PathBuf},
9    str::FromStr,
10};
11
12/// A `globset::Glob` that creates its `globset::GlobMatcher` when its created, so it doesn't need
13/// to be compiled when the filter functions `TestFilter` functions are called.
14#[derive(Clone, Debug)]
15pub struct GlobMatcher {
16    /// The compiled glob
17    pub matcher: globset::GlobMatcher,
18}
19
20impl GlobMatcher {
21    /// Creates a new `GlobMatcher` from a `globset::Glob`.
22    pub fn new(glob: globset::Glob) -> Self {
23        Self { matcher: glob.compile_matcher() }
24    }
25
26    /// Tests whether the given path matches this pattern or not.
27    ///
28    /// The glob `./test/*` won't match absolute paths like `test/Contract.sol`, which is common
29    /// format here, so we also handle this case here
30    pub fn is_match(&self, path: &Path) -> bool {
31        if self.matcher.is_match(path) {
32            return true;
33        }
34
35        if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
36            && file_name.contains(self.as_str())
37        {
38            return true;
39        }
40
41        if !path.starts_with("./") && self.as_str().starts_with("./") {
42            return self.matcher.is_match(format!("./{}", path.display()));
43        }
44
45        if path.is_relative() && Path::new(self.glob().glob()).is_absolute() {
46            if let Ok(canonicalized_path) = dunce::canonicalize(path) {
47                return self.matcher.is_match(&canonicalized_path);
48            }
49            return false;
50        }
51
52        false
53    }
54
55    /// Matches file only if the filter does not apply.
56    ///
57    /// This returns the inverse of `self.is_match(file)`.
58    fn is_match_exclude(&self, path: &Path) -> bool {
59        !self.is_match(path)
60    }
61
62    /// Returns the `globset::Glob`.
63    pub fn glob(&self) -> &globset::Glob {
64        self.matcher.glob()
65    }
66
67    /// Returns the `Glob` string used to compile this matcher.
68    pub fn as_str(&self) -> &str {
69        self.glob().glob()
70    }
71}
72
73impl fmt::Display for GlobMatcher {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        self.glob().fmt(f)
76    }
77}
78
79impl FromStr for GlobMatcher {
80    type Err = globset::Error;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        s.parse::<globset::Glob>().map(Self::new)
84    }
85}
86
87impl From<globset::Glob> for GlobMatcher {
88    fn from(glob: globset::Glob) -> Self {
89        Self::new(glob)
90    }
91}
92
93impl Serialize for GlobMatcher {
94    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
95        self.glob().glob().serialize(serializer)
96    }
97}
98
99impl<'de> Deserialize<'de> for GlobMatcher {
100    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        let s = String::deserialize(deserializer)?;
102        s.parse().map_err(serde::de::Error::custom)
103    }
104}
105
106impl PartialEq for GlobMatcher {
107    fn eq(&self, other: &Self) -> bool {
108        self.as_str() == other.as_str()
109    }
110}
111
112impl Eq for GlobMatcher {}
113
114/// Bundles multiple `SkipBuildFilter` into a single `FileFilter`
115#[derive(Clone, Debug)]
116pub struct SkipBuildFilters {
117    /// All provided filters.
118    pub matchers: Vec<GlobMatcher>,
119    /// Root of the project.
120    pub project_root: PathBuf,
121}
122
123impl FileFilter for SkipBuildFilters {
124    /// Only returns a match if _no_  exclusion filter matches
125    fn is_match(&self, file: &Path) -> bool {
126        self.matchers.iter().all(|matcher| {
127            if matcher.is_match_exclude(file) {
128                file.strip_prefix(&self.project_root)
129                    .map_or(true, |stripped| matcher.is_match_exclude(stripped))
130            } else {
131                false
132            }
133        })
134    }
135}
136
137impl SkipBuildFilters {
138    /// Creates a new `SkipBuildFilters` from multiple `SkipBuildFilter`.
139    pub fn new<G: Into<GlobMatcher>>(
140        filters: impl IntoIterator<Item = G>,
141        project_root: PathBuf,
142    ) -> Self {
143        let matchers = filters.into_iter().map(|m| m.into()).collect();
144        Self { matchers, project_root }
145    }
146}
147
148/// A filter that excludes matching contracts from the build
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub enum SkipBuildFilter {
151    /// Exclude all `.t.sol` contracts
152    Tests,
153    /// Exclude all `.s.sol` contracts
154    Scripts,
155    /// Exclude if the file matches
156    Custom(String),
157}
158
159impl SkipBuildFilter {
160    fn new(s: &str) -> Self {
161        match s {
162            "test" | "tests" => Self::Tests,
163            "script" | "scripts" => Self::Scripts,
164            s => Self::Custom(s.to_string()),
165        }
166    }
167
168    /// Returns the pattern to match against a file
169    pub const fn file_pattern(&self) -> &str {
170        match self {
171            Self::Tests => ".t.sol",
172            Self::Scripts => ".s.sol",
173            Self::Custom(s) => s.as_str(),
174        }
175    }
176}
177
178impl FromStr for SkipBuildFilter {
179    type Err = Infallible;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        Ok(Self::new(s))
183    }
184}
185
186/// Expand globs with a root path.
187///
188/// A trailing recursive component, like `src/**`, only matches subdirectories. Replace it with its
189/// parent so directory-aware consumers cover every descendant without enumerating the tree.
190pub fn expand_globs(
191    root: &Path,
192    patterns: impl IntoIterator<Item = impl AsRef<str>>,
193) -> eyre::Result<Vec<PathBuf>> {
194    let mut expanded = Vec::new();
195    for pattern in patterns {
196        let mut pattern = Path::new(pattern.as_ref());
197        if pattern.ends_with("**") {
198            pattern = pattern.parent().unwrap_or_else(|| Path::new(""));
199        }
200        for path in glob::glob(&root.join(pattern).display().to_string())? {
201            expanded.push(path?);
202        }
203    }
204    Ok(expanded)
205}
206
207/// Returns whether `file` is contained in `paths`, or lies underneath a directory contained in
208/// `paths`.
209///
210/// Expanded ignore globs can contain directories, e.g. `test/**` yields the directories under
211/// `test`, so an ignore check must also match the files below every entry. `base` is used to
212/// resolve `file` when it is relative.
213pub fn is_ignored_path(file: &Path, paths: &[PathBuf], base: &Path) -> bool {
214    let joined = base.join(file);
215    paths.iter().any(|path| file.starts_with(path) || joined.starts_with(path))
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::fs;
222    use tempfile::tempdir;
223
224    #[test]
225    fn test_expand_trailing_recursive_glob() {
226        let root = tempdir().unwrap();
227        let src = root.path().join("src");
228        fs::create_dir(&src).unwrap();
229
230        assert_eq!(expand_globs(root.path(), ["src/**"]).unwrap(), vec![src]);
231    }
232
233    #[test]
234    fn test_is_ignored_path() {
235        let base = Path::new("/root");
236        let ignored = [PathBuf::from("/root/test"), PathBuf::from("/root/src/A.sol")];
237
238        // Exact file matches, absolute and relative.
239        assert!(is_ignored_path(Path::new("/root/src/A.sol"), &ignored, base));
240        assert!(is_ignored_path(Path::new("src/A.sol"), &ignored, base));
241
242        // Files under an ignored directory.
243        assert!(is_ignored_path(Path::new("/root/test/B.t.sol"), &ignored, base));
244        assert!(is_ignored_path(Path::new("test/sub/C.t.sol"), &ignored, base));
245
246        // A directory prefix only matches whole path components.
247        assert!(!is_ignored_path(Path::new("/root/testOther.sol"), &ignored, base));
248        assert!(!is_ignored_path(Path::new("/root/src/B.sol"), &ignored, base));
249    }
250
251    #[test]
252    fn test_build_filter() {
253        let tests = GlobMatcher::from_str(SkipBuildFilter::Tests.file_pattern()).unwrap();
254        let scripts = GlobMatcher::from_str(SkipBuildFilter::Scripts.file_pattern()).unwrap();
255        let custom = |s| GlobMatcher::from_str(s).unwrap();
256
257        let file = Path::new("A.t.sol");
258        assert!(!tests.is_match_exclude(file));
259        assert!(scripts.is_match_exclude(file));
260        assert!(!custom("A.t").is_match_exclude(file));
261
262        let file = Path::new("A.s.sol");
263        assert!(tests.is_match_exclude(file));
264        assert!(!scripts.is_match_exclude(file));
265        assert!(!custom("A.s").is_match_exclude(file));
266
267        let file = Path::new("/home/test/Foo.sol");
268        assert!(!custom("*/test/**").is_match_exclude(file));
269
270        let file = Path::new("/home/script/Contract.sol");
271        assert!(!custom("*/script/**").is_match_exclude(file));
272    }
273
274    #[test]
275    fn can_match_relative_glob_paths() {
276        let matcher: GlobMatcher = "./test/*".parse().unwrap();
277
278        // Absolute path that should match the pattern
279        assert!(matcher.is_match(Path::new("test/Contract.t.sol")));
280
281        // Relative path that should match the pattern
282        assert!(matcher.is_match(Path::new("./test/Contract.t.sol")));
283    }
284
285    #[test]
286    fn can_match_absolute_glob_paths() {
287        let matcher: GlobMatcher = "/home/user/projects/project/test/*".parse().unwrap();
288
289        // Absolute path that should match the pattern
290        assert!(matcher.is_match(Path::new("/home/user/projects/project/test/Contract.t.sol")));
291
292        // Absolute path that should not match the pattern
293        assert!(!matcher.is_match(Path::new("/home/user/other/project/test/Contract.t.sol")));
294
295        // Relative path that should not match an absolute pattern
296        assert!(!matcher.is_match(Path::new("projects/project/test/Contract.t.sol")));
297    }
298}