Skip to main content

forge/cmd/test/
filter.rs

1use alloy_json_abi::Function;
2use clap::Parser;
3use foundry_common::{TestFilter, TestFunctionExt};
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, 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        if self.test_pattern.is_none() {
79            self.test_pattern = config.test_pattern.clone().map(Into::into);
80        }
81        if self.test_pattern_inverse.is_none() {
82            self.test_pattern_inverse = config.test_pattern_inverse.clone().map(Into::into);
83        }
84        if self.contract_pattern.is_none() {
85            self.contract_pattern = config.contract_pattern.clone().map(Into::into);
86        }
87        if self.contract_pattern_inverse.is_none() {
88            self.contract_pattern_inverse = config.contract_pattern_inverse.clone().map(Into::into);
89        }
90        if self.path_pattern.is_none() {
91            self.path_pattern = config.path_pattern.clone().map(Into::into);
92        }
93        if self.path_pattern_inverse.is_none() {
94            self.path_pattern_inverse = config.path_pattern_inverse.clone().map(Into::into);
95        }
96        if self.coverage_pattern_inverse.is_none() {
97            self.coverage_pattern_inverse = config.coverage_pattern_inverse.clone().map(Into::into);
98        }
99        ProjectPathsAwareFilter {
100            args_filter: self,
101            paths: config.project_paths(),
102            rerun_failures: None,
103        }
104    }
105}
106
107impl fmt::Debug for FilterArgs {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("FilterArgs")
110            .field("match-test", &self.test_pattern.as_ref().map(|r| r.as_str()))
111            .field("no-match-test", &self.test_pattern_inverse.as_ref().map(|r| r.as_str()))
112            .field("match-contract", &self.contract_pattern.as_ref().map(|r| r.as_str()))
113            .field("no-match-contract", &self.contract_pattern_inverse.as_ref().map(|r| r.as_str()))
114            .field("match-path", &self.path_pattern.as_ref().map(|g| g.as_str()))
115            .field("no-match-path", &self.path_pattern_inverse.as_ref().map(|g| g.as_str()))
116            .field("no-match-coverage", &self.coverage_pattern_inverse.as_ref().map(|g| g.as_str()))
117            .finish_non_exhaustive()
118    }
119}
120
121impl FileFilter for FilterArgs {
122    /// Returns true if the file regex pattern match the `file`
123    ///
124    /// If no file regex is set this returns true by default
125    fn is_match(&self, file: &Path) -> bool {
126        self.matches_path(file)
127    }
128}
129
130impl TestFilter for FilterArgs {
131    fn matches_test(&self, test_signature: &str) -> bool {
132        let mut ok = true;
133        if let Some(re) = &self.test_pattern {
134            ok = ok && re.is_match(test_signature);
135        }
136        if let Some(re) = &self.test_pattern_inverse {
137            ok = ok && !re.is_match(test_signature);
138        }
139        ok
140    }
141
142    fn matches_contract(&self, contract_name: &str) -> bool {
143        let mut ok = true;
144        if let Some(re) = &self.contract_pattern {
145            ok = ok && re.is_match(contract_name);
146        }
147        if let Some(re) = &self.contract_pattern_inverse {
148            ok = ok && !re.is_match(contract_name);
149        }
150        ok
151    }
152
153    fn matches_path(&self, path: &Path) -> bool {
154        let mut ok = true;
155        if let Some(re) = &self.path_pattern {
156            ok = ok && re.is_match(path);
157        }
158        if let Some(re) = &self.path_pattern_inverse {
159            ok = ok && !re.is_match(path);
160        }
161        ok
162    }
163}
164
165impl fmt::Display for FilterArgs {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        if let Some(p) = &self.test_pattern {
168            writeln!(f, "\tmatch-test: `{}`", p.as_str())?;
169        }
170        if let Some(p) = &self.test_pattern_inverse {
171            writeln!(f, "\tno-match-test: `{}`", p.as_str())?;
172        }
173        if let Some(p) = &self.contract_pattern {
174            writeln!(f, "\tmatch-contract: `{}`", p.as_str())?;
175        }
176        if let Some(p) = &self.contract_pattern_inverse {
177            writeln!(f, "\tno-match-contract: `{}`", p.as_str())?;
178        }
179        if let Some(p) = &self.path_pattern {
180            writeln!(f, "\tmatch-path: `{}`", p.as_str())?;
181        }
182        if let Some(p) = &self.path_pattern_inverse {
183            writeln!(f, "\tno-match-path: `{}`", p.as_str())?;
184        }
185        if let Some(p) = &self.coverage_pattern_inverse {
186            writeln!(f, "\tno-match-coverage: `{}`", p.as_str())?;
187        }
188        Ok(())
189    }
190}
191
192/// A filter that combines all command line arguments and the paths of the current projects
193#[derive(Clone, Debug)]
194pub struct ProjectPathsAwareFilter {
195    args_filter: FilterArgs,
196    paths: ProjectPathsConfig,
197    rerun_failures: Option<Vec<RerunFailure>>,
198}
199
200impl ProjectPathsAwareFilter {
201    /// Returns true if the filter is empty.
202    pub const fn is_empty(&self) -> bool {
203        self.args_filter.is_empty()
204    }
205
206    /// Returns the CLI arguments.
207    pub const fn args(&self) -> &FilterArgs {
208        &self.args_filter
209    }
210
211    /// Returns the CLI arguments mutably.
212    pub const fn args_mut(&mut self) -> &mut FilterArgs {
213        &mut self.args_filter
214    }
215
216    /// Returns the project paths.
217    pub const fn paths(&self) -> &ProjectPathsConfig {
218        &self.paths
219    }
220
221    /// Sets exact contract/test pairs persisted by `forge test --rerun`.
222    pub fn set_rerun_failures(&mut self, failures: Vec<RerunFailure>) {
223        self.rerun_failures = Some(failures);
224    }
225}
226
227impl FileFilter for ProjectPathsAwareFilter {
228    /// Returns true if the file regex pattern match the `file`
229    ///
230    /// If no file regex is set this returns true by default
231    fn is_match(&self, mut file: &Path) -> bool {
232        file = file.strip_prefix(&self.paths.root).unwrap_or(file);
233        self.args_filter.is_match(file)
234    }
235}
236
237impl TestFilter for ProjectPathsAwareFilter {
238    fn matches_test(&self, test_signature: &str) -> bool {
239        self.args_filter.matches_test(test_signature)
240    }
241
242    fn matches_contract(&self, contract_name: &str) -> bool {
243        self.args_filter.matches_contract(contract_name)
244    }
245
246    fn matches_path(&self, mut path: &Path) -> bool {
247        // we don't want to test files that belong to a library
248        path = path.strip_prefix(&self.paths.root).unwrap_or(path);
249        self.args_filter.matches_path(path) && !self.paths.has_library_ancestor(path)
250    }
251
252    fn matches_test_function_in_contract(&self, contract_id: &str, func: &Function) -> bool {
253        if let Some(failures) = &self.rerun_failures {
254            if !func.is_any_test() || !self.args_filter.matches_test(&func.signature()) {
255                return false;
256            }
257            let signature = func.signature();
258            let name = signature.split('(').next().unwrap_or(&signature);
259            failures.iter().any(|failure| {
260                failure.contract == contract_id
261                    && (failure.test == signature || failure.test == name)
262            })
263        } else {
264            func.is_any_test() && self.args_filter.matches_test(&func.signature())
265        }
266    }
267}
268
269impl fmt::Display for ProjectPathsAwareFilter {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        self.args_filter.fmt(f)
272    }
273}