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            if file_name.contains(self.as_str()) {
51                return true;
52            }
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            } else {
63                return false;
64            }
65        }
66
67        false
68    }
69
70    /// Matches file only if the filter does not apply.
71    ///
72    /// This returns the inverse of `self.is_match(file)`.
73    fn is_match_exclude(&self, path: &Path) -> bool {
74        !self.is_match(path)
75    }
76
77    /// Returns the `globset::Glob`.
78    pub fn glob(&self) -> &globset::Glob {
79        self.matcher.glob()
80    }
81
82    /// Returns the `Glob` string used to compile this matcher.
83    pub fn as_str(&self) -> &str {
84        self.glob().glob()
85    }
86}
87
88impl fmt::Display for GlobMatcher {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        self.glob().fmt(f)
91    }
92}
93
94impl FromStr for GlobMatcher {
95    type Err = globset::Error;
96
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        s.parse::<globset::Glob>().map(Self::new)
99    }
100}
101
102impl From<globset::Glob> for GlobMatcher {
103    fn from(glob: globset::Glob) -> Self {
104        Self::new(glob)
105    }
106}
107
108impl Serialize for GlobMatcher {
109    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110        self.glob().glob().serialize(serializer)
111    }
112}
113
114impl<'de> Deserialize<'de> for GlobMatcher {
115    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116        let s = String::deserialize(deserializer)?;
117        s.parse().map_err(serde::de::Error::custom)
118    }
119}
120
121impl PartialEq for GlobMatcher {
122    fn eq(&self, other: &Self) -> bool {
123        self.as_str() == other.as_str()
124    }
125}
126
127impl Eq for GlobMatcher {}
128
129/// Bundles multiple `SkipBuildFilter` into a single `FileFilter`
130#[derive(Clone, Debug)]
131pub struct SkipBuildFilters {
132    /// All provided filters.
133    pub matchers: Vec<GlobMatcher>,
134    /// Root of the project.
135    pub project_root: PathBuf,
136}
137
138impl FileFilter for SkipBuildFilters {
139    /// Only returns a match if _no_  exclusion filter matches
140    fn is_match(&self, file: &Path) -> bool {
141        self.matchers.iter().all(|matcher| {
142            if !matcher.is_match_exclude(file) {
143                false
144            } else {
145                file.strip_prefix(&self.project_root)
146                    .map_or(true, |stripped| matcher.is_match_exclude(stripped))
147            }
148        })
149    }
150}
151
152impl SkipBuildFilters {
153    /// Creates a new `SkipBuildFilters` from multiple `SkipBuildFilter`.
154    pub fn new<G: Into<GlobMatcher>>(
155        filters: impl IntoIterator<Item = G>,
156        project_root: PathBuf,
157    ) -> Self {
158        let matchers = filters.into_iter().map(|m| m.into()).collect();
159        Self { matchers, project_root }
160    }
161}
162
163/// A filter that excludes matching contracts from the build
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub enum SkipBuildFilter {
166    /// Exclude all `.t.sol` contracts
167    Tests,
168    /// Exclude all `.s.sol` contracts
169    Scripts,
170    /// Exclude if the file matches
171    Custom(String),
172}
173
174impl SkipBuildFilter {
175    fn new(s: &str) -> Self {
176        match s {
177            "test" | "tests" => Self::Tests,
178            "script" | "scripts" => Self::Scripts,
179            s => Self::Custom(s.to_string()),
180        }
181    }
182
183    /// Returns the pattern to match against a file
184    pub fn file_pattern(&self) -> &str {
185        match self {
186            Self::Tests => ".t.sol",
187            Self::Scripts => ".s.sol",
188            Self::Custom(s) => s.as_str(),
189        }
190    }
191}
192
193impl FromStr for SkipBuildFilter {
194    type Err = Infallible;
195
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        Ok(Self::new(s))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_build_filter() {
207        let tests = GlobMatcher::from_str(SkipBuildFilter::Tests.file_pattern()).unwrap();
208        let scripts = GlobMatcher::from_str(SkipBuildFilter::Scripts.file_pattern()).unwrap();
209        let custom = |s| GlobMatcher::from_str(s).unwrap();
210
211        let file = Path::new("A.t.sol");
212        assert!(!tests.is_match_exclude(file));
213        assert!(scripts.is_match_exclude(file));
214        assert!(!custom("A.t").is_match_exclude(file));
215
216        let file = Path::new("A.s.sol");
217        assert!(tests.is_match_exclude(file));
218        assert!(!scripts.is_match_exclude(file));
219        assert!(!custom("A.s").is_match_exclude(file));
220
221        let file = Path::new("/home/test/Foo.sol");
222        assert!(!custom("*/test/**").is_match_exclude(file));
223
224        let file = Path::new("/home/script/Contract.sol");
225        assert!(!custom("*/script/**").is_match_exclude(file));
226    }
227
228    #[test]
229    fn can_match_relative_glob_paths() {
230        let matcher: GlobMatcher = "./test/*".parse().unwrap();
231
232        // Absolute path that should match the pattern
233        assert!(matcher.is_match(Path::new("test/Contract.t.sol")));
234
235        // Relative path that should match the pattern
236        assert!(matcher.is_match(Path::new("./test/Contract.t.sol")));
237    }
238
239    #[test]
240    fn can_match_absolute_glob_paths() {
241        let matcher: GlobMatcher = "/home/user/projects/project/test/*".parse().unwrap();
242
243        // Absolute path that should match the pattern
244        assert!(matcher.is_match(Path::new("/home/user/projects/project/test/Contract.t.sol")));
245
246        // Absolute path that should not match the pattern
247        assert!(!matcher.is_match(Path::new("/home/user/other/project/test/Contract.t.sol")));
248
249        // Relative path that should not match an absolute pattern
250        assert!(!matcher.is_match(Path::new("projects/project/test/Contract.t.sol")));
251    }
252}