Skip to main content

forge/cmd/test/
filter.rs

1use alloy_json_abi::Function;
2use clap::Parser;
3use foundry_common::{TestFilter, TestFunctionKind};
4use foundry_compilers::{FileFilter, ProjectPathsConfig};
5use foundry_config::{Config, filter::GlobMatcher};
6use serde::{Deserialize, Serialize};
7use std::{fmt, path::Path};
8
9/// A failed test persisted for `forge test --rerun`.
10#[derive(Clone, Debug, Deserialize, Serialize)]
11pub struct RerunFailure {
12    /// The test suite identifier (`path:contract_name`).
13    pub contract: String,
14    /// The test signature or invariant predicate name.
15    pub test: String,
16}
17
18/// Persisted `forge test --rerun` failures.
19#[derive(Clone, Debug, Deserialize, Serialize)]
20pub struct RerunFailures {
21    pub version: u8,
22    pub failures: Vec<RerunFailure>,
23}
24
25/// The filter to use during testing.
26///
27/// See also `FileFilter`.
28#[derive(Clone, Default, Parser)]
29#[command(next_help_heading = "Test filtering")]
30pub struct FilterArgs {
31    /// Only run test functions matching the specified regex pattern.
32    #[arg(long = "match-test", visible_alias = "mt", value_name = "REGEX")]
33    pub test_pattern: Option<regex::Regex>,
34
35    /// Only run test functions that do not match the specified regex pattern.
36    #[arg(long = "no-match-test", visible_alias = "nmt", value_name = "REGEX")]
37    pub test_pattern_inverse: Option<regex::Regex>,
38
39    /// Only run tests in contracts matching the specified regex pattern.
40    #[arg(long = "match-contract", visible_alias = "mc", value_name = "REGEX")]
41    pub contract_pattern: Option<regex::Regex>,
42
43    /// Only run tests in contracts that do not match the specified regex pattern.
44    #[arg(long = "no-match-contract", visible_alias = "nmc", value_name = "REGEX")]
45    pub contract_pattern_inverse: Option<regex::Regex>,
46
47    /// Only run tests in source files matching the specified glob pattern.
48    #[arg(long = "match-path", visible_alias = "mp", value_name = "GLOB")]
49    pub path_pattern: Option<GlobMatcher>,
50
51    /// Only run tests in source files that do not match the specified glob pattern.
52    #[arg(
53        id = "no-match-path",
54        long = "no-match-path",
55        visible_alias = "nmp",
56        value_name = "GLOB"
57    )]
58    pub path_pattern_inverse: Option<GlobMatcher>,
59
60    /// Only show coverage for files that do not match the specified regex pattern.
61    #[arg(long = "no-match-coverage", visible_alias = "nmco", value_name = "REGEX")]
62    pub coverage_pattern_inverse: Option<regex::Regex>,
63}
64
65impl FilterArgs {
66    /// Returns true if the filter is empty.
67    pub const fn is_empty(&self) -> bool {
68        self.test_pattern.is_none()
69            && self.test_pattern_inverse.is_none()
70            && self.contract_pattern.is_none()
71            && self.contract_pattern_inverse.is_none()
72            && self.path_pattern.is_none()
73            && self.path_pattern_inverse.is_none()
74    }
75
76    /// Merges the set filter globs with the config's values
77    pub fn merge_with_config(mut self, config: &Config) -> ProjectPathsAwareFilter {
78        self.test_pattern =
79            self.test_pattern.or_else(|| config.test_pattern.clone().map(Into::into));
80        self.test_pattern_inverse = self
81            .test_pattern_inverse
82            .or_else(|| config.test_pattern_inverse.clone().map(Into::into));
83        self.contract_pattern =
84            self.contract_pattern.or_else(|| config.contract_pattern.clone().map(Into::into));
85        self.contract_pattern_inverse = self
86            .contract_pattern_inverse
87            .or_else(|| config.contract_pattern_inverse.clone().map(Into::into));
88        self.path_pattern =
89            self.path_pattern.or_else(|| config.path_pattern.clone().map(Into::into));
90        self.path_pattern_inverse = self
91            .path_pattern_inverse
92            .or_else(|| config.path_pattern_inverse.clone().map(Into::into));
93        self.coverage_pattern_inverse = self
94            .coverage_pattern_inverse
95            .or_else(|| config.coverage_pattern_inverse.clone().map(Into::into));
96        ProjectPathsAwareFilter {
97            args_filter: self,
98            paths: config.project_paths(),
99            rerun_failures: None,
100        }
101    }
102
103    /// Returns all patterns as `(flag, pattern)` pairs.
104    fn patterns(&self) -> [(&'static str, Option<&str>); 7] {
105        [
106            ("match-test", self.test_pattern.as_ref().map(|r| r.as_str())),
107            ("no-match-test", self.test_pattern_inverse.as_ref().map(|r| r.as_str())),
108            ("match-contract", self.contract_pattern.as_ref().map(|r| r.as_str())),
109            ("no-match-contract", self.contract_pattern_inverse.as_ref().map(|r| r.as_str())),
110            ("match-path", self.path_pattern.as_ref().map(|g| g.as_str())),
111            ("no-match-path", self.path_pattern_inverse.as_ref().map(|g| g.as_str())),
112            ("no-match-coverage", self.coverage_pattern_inverse.as_ref().map(|r| r.as_str())),
113        ]
114    }
115}
116
117impl fmt::Debug for FilterArgs {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        let mut s = f.debug_struct("FilterArgs");
120        for (name, pattern) in self.patterns() {
121            s.field(name, &pattern);
122        }
123        s.finish_non_exhaustive()
124    }
125}
126
127impl FileFilter for FilterArgs {
128    /// Returns true if the file regex pattern match the `file`
129    ///
130    /// If no file regex is set this returns true by default
131    fn is_match(&self, file: &Path) -> bool {
132        self.matches_path(file)
133    }
134}
135
136impl TestFilter for FilterArgs {
137    fn matches_test(&self, test_signature: &str) -> bool {
138        self.test_pattern.as_ref().is_none_or(|re| re.is_match(test_signature))
139            && self.test_pattern_inverse.as_ref().is_none_or(|re| !re.is_match(test_signature))
140    }
141
142    fn matches_contract(&self, contract_name: &str) -> bool {
143        self.contract_pattern.as_ref().is_none_or(|re| re.is_match(contract_name))
144            && self.contract_pattern_inverse.as_ref().is_none_or(|re| !re.is_match(contract_name))
145    }
146
147    fn matches_path(&self, path: &Path) -> bool {
148        self.path_pattern.as_ref().is_none_or(|g| g.is_match(path))
149            && self.path_pattern_inverse.as_ref().is_none_or(|g| !g.is_match(path))
150    }
151}
152
153impl fmt::Display for FilterArgs {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        for (name, pattern) in self.patterns() {
156            if let Some(pattern) = pattern {
157                writeln!(f, "\t{name}: `{pattern}`")?;
158            }
159        }
160        Ok(())
161    }
162}
163
164/// A filter that combines all command line arguments and the paths of the current projects
165#[derive(Clone, Debug)]
166pub struct ProjectPathsAwareFilter {
167    args_filter: FilterArgs,
168    paths: ProjectPathsConfig,
169    rerun_failures: Option<Vec<RerunFailure>>,
170}
171
172impl ProjectPathsAwareFilter {
173    /// Returns true if the filter is empty.
174    pub const fn is_empty(&self) -> bool {
175        self.args_filter.is_empty()
176    }
177
178    /// Returns the CLI arguments.
179    pub const fn args(&self) -> &FilterArgs {
180        &self.args_filter
181    }
182
183    /// Returns the CLI arguments mutably.
184    pub const fn args_mut(&mut self) -> &mut FilterArgs {
185        &mut self.args_filter
186    }
187
188    /// Returns the project paths.
189    pub const fn paths(&self) -> &ProjectPathsConfig {
190        &self.paths
191    }
192
193    /// Sets exact contract/test pairs persisted by `forge test --rerun`.
194    pub fn set_rerun_failures(&mut self, failures: Vec<RerunFailure>) {
195        self.rerun_failures = Some(failures);
196    }
197
198    /// Returns exact contract/test pairs persisted by `forge test --rerun`.
199    pub fn rerun_failures(&self) -> Option<&[RerunFailure]> {
200        self.rerun_failures.as_deref()
201    }
202
203    fn matches_rerun_contract(&self, failure_contract: &str, contract_id: &str) -> bool {
204        if failure_contract == contract_id {
205            return true;
206        }
207        let (Some((failure_path, failure_name)), Some((contract_path, contract_name))) =
208            (failure_contract.rsplit_once(':'), contract_id.rsplit_once(':'))
209        else {
210            return false;
211        };
212        if failure_name != contract_name {
213            return false;
214        }
215
216        let normalize = |path: &str| {
217            let path = Path::new(path);
218            if let Ok(path) = path.strip_prefix(&self.paths.root) {
219                return path.to_path_buf();
220            }
221            if path.is_absolute()
222                && let Ok(root) = dunce::canonicalize(&self.paths.root)
223                && let Ok(path) = dunce::canonicalize(path)
224                && let Ok(path) = path.strip_prefix(root)
225            {
226                return path.to_path_buf();
227            }
228            path.to_path_buf()
229        };
230        normalize(failure_path) == normalize(contract_path)
231    }
232}
233
234impl FileFilter for ProjectPathsAwareFilter {
235    /// Returns true if the file regex pattern match the `file`
236    ///
237    /// If no file regex is set this returns true by default
238    fn is_match(&self, mut file: &Path) -> bool {
239        file = file.strip_prefix(&self.paths.root).unwrap_or(file);
240        self.args_filter.is_match(file)
241    }
242}
243
244impl TestFilter for ProjectPathsAwareFilter {
245    fn matches_test(&self, test_signature: &str) -> bool {
246        self.args_filter.matches_test(test_signature)
247    }
248
249    fn matches_contract(&self, contract_name: &str) -> bool {
250        self.args_filter.matches_contract(contract_name)
251    }
252
253    fn matches_path(&self, mut path: &Path) -> bool {
254        // we don't want to test files that belong to a library
255        path = path.strip_prefix(&self.paths.root).unwrap_or(path);
256        self.args_filter.matches_path(path) && !self.paths.has_library_ancestor(path)
257    }
258
259    fn matches_test_function_kind_in_contract(
260        &self,
261        contract_id: &str,
262        func: &Function,
263        kind: TestFunctionKind,
264    ) -> bool {
265        let signature = func.signature();
266        if !kind.is_any_test() || !self.args_filter.matches_test(&signature) {
267            return false;
268        }
269        let Some(failures) = &self.rerun_failures else { return true };
270        let name = signature.split('(').next().unwrap_or(&signature);
271        failures.iter().any(|failure| {
272            self.matches_rerun_contract(&failure.contract, contract_id)
273                && (failure.test == signature || failure.test == name)
274        })
275    }
276}
277
278impl fmt::Display for ProjectPathsAwareFilter {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        self.args_filter.fmt(f)
281    }
282}