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/// Expand globs with a root path.
13pub fn expand_globs(
14    root: &Path,
15    patterns: impl IntoIterator<Item = impl AsRef<str>>,
16) -> eyre::Result<Vec<PathBuf>> {
17    let mut expanded = Vec::new();
18    for pattern in patterns {
19        for paths in glob::glob(&root.join(pattern.as_ref()).display().to_string())? {
20            expanded.push(paths?);
21        }
22    }
23    Ok(expanded)
24}
25
26/// A `globset::Glob` that creates its `globset::GlobMatcher` when its created, so it doesn't need
27/// to be compiled when the filter functions `TestFilter` functions are called.
28#[derive(Clone, Debug)]
29pub struct GlobMatcher {
30    /// The compiled glob
31    pub matcher: globset::GlobMatcher,
32}
33
34impl GlobMatcher {
35    /// Creates a new `GlobMatcher` from a `globset::Glob`.
36    pub fn new(glob: globset::Glob) -> Self {
37        Self { matcher: glob.compile_matcher() }
38    }
39
40    /// Tests whether the given path matches this pattern or not.
41    ///
42    /// The glob `./test/*` won't match absolute paths like `test/Contract.sol`, which is common
43    /// format here, so we also handle this case here
44    pub fn is_match(&self, path: &Path) -> bool {
45        if self.matcher.is_match(path) {
46            return true;
47        }
48
49        if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
50            && file_name.contains(self.as_str())
51        {
52            return true;
53        }
54
55        if !path.starts_with("./") && self.as_str().starts_with("./") {
56            return self.matcher.is_match(format!("./{}", path.display()));
57        }
58
59        if path.is_relative() && Path::new(self.glob().glob()).is_absolute() {
60            if let Ok(canonicalized_path) = dunce::canonicalize(path) {
61                return self.matcher.is_match(&canonicalized_path);
62            }
63            return false;
64        }
65
66        false
67    }
68
69    /// Matches file only if the filter does not apply.
70    ///
71    /// This returns the inverse of `self.is_match(file)`.
72    fn is_match_exclude(&self, path: &Path) -> bool {
73        !self.is_match(path)
74    }
75
76    /// Returns the `globset::Glob`.
77    pub fn glob(&self) -> &globset::Glob {
78        self.matcher.glob()
79    }
80
81    /// Returns the `Glob` string used to compile this matcher.
82    pub fn as_str(&self) -> &str {
83        self.glob().glob()
84    }
85}
86
87impl fmt::Display for GlobMatcher {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        self.glob().fmt(f)
90    }
91}
92
93impl FromStr for GlobMatcher {
94    type Err = globset::Error;
95
96    fn from_str(s: &str) -> Result<Self, Self::Err> {
97        s.parse::<globset::Glob>().map(Self::new)
98    }
99}
100
101impl From<globset::Glob> for GlobMatcher {
102    fn from(glob: globset::Glob) -> Self {
103        Self::new(glob)
104    }
105}
106
107impl Serialize for GlobMatcher {
108    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
109        self.glob().glob().serialize(serializer)
110    }
111}
112
113impl<'de> Deserialize<'de> for GlobMatcher {
114    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
115        let s = String::deserialize(deserializer)?;
116        s.parse().map_err(serde::de::Error::custom)
117    }
118}
119
120impl PartialEq for GlobMatcher {
121    fn eq(&self, other: &Self) -> bool {
122        self.as_str() == other.as_str()
123    }
124}
125
126impl Eq for GlobMatcher {}
127
128/// Bundles multiple `SkipBuildFilter` into a single `FileFilter`
129#[derive(Clone, Debug)]
130pub struct SkipBuildFilters {
131    /// All provided filters.
132    pub matchers: Vec<GlobMatcher>,
133    /// Root of the project.
134    pub project_root: PathBuf,
135}
136
137impl FileFilter for SkipBuildFilters {
138    /// Only returns a match if _no_  exclusion filter matches
139    fn is_match(&self, file: &Path) -> bool {
140        self.matchers.iter().all(|matcher| {
141            if matcher.is_match_exclude(file) {
142                file.strip_prefix(&self.project_root)
143                    .map_or(true, |stripped| matcher.is_match_exclude(stripped))
144            } else {
145                false
146            }
147        })
148    }
149}
150
151impl SkipBuildFilters {
152    /// Creates a new `SkipBuildFilters` from multiple `SkipBuildFilter`.
153    pub fn new<G: Into<GlobMatcher>>(
154        filters: impl IntoIterator<Item = G>,
155        project_root: PathBuf,
156    ) -> Self {
157        let matchers = filters.into_iter().map(|m| m.into()).collect();
158        Self { matchers, project_root }
159    }
160}
161
162/// A filter that excludes matching contracts from the build
163#[derive(Clone, Debug, PartialEq, Eq)]
164pub enum SkipBuildFilter {
165    /// Exclude all `.t.sol` contracts
166    Tests,
167    /// Exclude all `.s.sol` contracts
168    Scripts,
169    /// Exclude if the file matches
170    Custom(String),
171}
172
173impl SkipBuildFilter {
174    fn new(s: &str) -> Self {
175        match s {
176            "test" | "tests" => Self::Tests,
177            "script" | "scripts" => Self::Scripts,
178            s => Self::Custom(s.to_string()),
179        }
180    }
181
182    /// Returns the pattern to match against a file
183    pub fn file_pattern(&self) -> &str {
184        match self {
185            Self::Tests => ".t.sol",
186            Self::Scripts => ".s.sol",
187            Self::Custom(s) => s.as_str(),
188        }
189    }
190}
191
192impl FromStr for SkipBuildFilter {
193    type Err = Infallible;
194
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        Ok(Self::new(s))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_build_filter() {
206        let tests = GlobMatcher::from_str(SkipBuildFilter::Tests.file_pattern()).unwrap();
207        let scripts = GlobMatcher::from_str(SkipBuildFilter::Scripts.file_pattern()).unwrap();
208        let custom = |s| GlobMatcher::from_str(s).unwrap();
209
210        let file = Path::new("A.t.sol");
211        assert!(!tests.is_match_exclude(file));
212        assert!(scripts.is_match_exclude(file));
213        assert!(!custom("A.t").is_match_exclude(file));
214
215        let file = Path::new("A.s.sol");
216        assert!(tests.is_match_exclude(file));
217        assert!(!scripts.is_match_exclude(file));
218        assert!(!custom("A.s").is_match_exclude(file));
219
220        let file = Path::new("/home/test/Foo.sol");
221        assert!(!custom("*/test/**").is_match_exclude(file));
222
223        let file = Path::new("/home/script/Contract.sol");
224        assert!(!custom("*/script/**").is_match_exclude(file));
225    }
226
227    #[test]
228    fn can_match_relative_glob_paths() {
229        let matcher: GlobMatcher = "./test/*".parse().unwrap();
230
231        // Absolute path that should match the pattern
232        assert!(matcher.is_match(Path::new("test/Contract.t.sol")));
233
234        // Relative path that should match the pattern
235        assert!(matcher.is_match(Path::new("./test/Contract.t.sol")));
236    }
237
238    #[test]
239    fn can_match_absolute_glob_paths() {
240        let matcher: GlobMatcher = "/home/user/projects/project/test/*".parse().unwrap();
241
242        // Absolute path that should match the pattern
243        assert!(matcher.is_match(Path::new("/home/user/projects/project/test/Contract.t.sol")));
244
245        // Absolute path that should not match the pattern
246        assert!(!matcher.is_match(Path::new("/home/user/other/project/test/Contract.t.sol")));
247
248        // Relative path that should not match an absolute pattern
249        assert!(!matcher.is_match(Path::new("projects/project/test/Contract.t.sol")));
250    }
251}