Skip to content

Commit 20b3da1

Browse files
feat(coverage): add option to ignore directories and files from coverage report (#8321)
* feat: add option to ignore directories from coverage report * add docs, rename no-coverage-path to ignore-coverage-path * cargo fmt * small refactor * revert formatting changes * revert formatting * path_pattern_ignore_coverage -> coverage_pattern_inverse * use regex instead of glob * re-enable ignoring of sources after report * fix formatting * add basic filter test * remove redundant Path cast * use HashMap::retain * greatly simplify, remove CoverageFilter * move constants out of filter map --------- Co-authored-by: dimazhornyk <[email protected]> Co-authored-by: Dima Zhornyk <[email protected]>
1 parent afcf5b1 commit 20b3da1

File tree

8 files changed

+171
-9
lines changed

8 files changed

+171
-9
lines changed

crates/config/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ match_contract = "Foo"
114114
no_match_contract = "Bar"
115115
match_path = "*/Foo*"
116116
no_match_path = "*/Bar*"
117+
no_match_coverage = "Baz"
117118
ffi = false
118119
always_use_create_2_factory = false
119120
prompt_timeout = 120

crates/config/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ pub struct Config {
252252
/// Only run tests in source files that do not match the specified glob pattern.
253253
#[serde(rename = "no_match_path", with = "from_opt_glob")]
254254
pub path_pattern_inverse: Option<globset::Glob>,
255+
/// Only show coverage for files that do not match the specified regex pattern.
256+
#[serde(rename = "no_match_coverage")]
257+
pub coverage_pattern_inverse: Option<RegexWrapper>,
255258
/// Configuration for fuzz testing
256259
pub fuzz: FuzzConfig,
257260
/// Configuration for invariant testing
@@ -2073,6 +2076,7 @@ impl Default for Config {
20732076
contract_pattern_inverse: None,
20742077
path_pattern: None,
20752078
path_pattern_inverse: None,
2079+
coverage_pattern_inverse: None,
20762080
fuzz: FuzzConfig::new("cache/fuzz".into()),
20772081
invariant: InvariantConfig::new("cache/invariant".into()),
20782082
always_use_create_2_factory: false,

crates/evm/coverage/src/lib.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,17 @@
99
extern crate tracing;
1010

1111
use alloy_primitives::{Bytes, B256};
12+
use eyre::{Context, Result};
1213
use foundry_compilers::artifacts::sourcemap::SourceMap;
1314
use semver::Version;
1415
use std::{
1516
collections::{BTreeMap, HashMap},
1617
fmt::Display,
1718
ops::{AddAssign, Deref, DerefMut},
18-
path::PathBuf,
19+
path::{Path, PathBuf},
1920
sync::Arc,
2021
};
2122

22-
use eyre::{Context, Result};
23-
2423
pub mod analysis;
2524
pub mod anchors;
2625

@@ -150,6 +149,22 @@ impl CoverageReport {
150149
}
151150
Ok(())
152151
}
152+
153+
/// Removes all the coverage items that should be ignored by the filter.
154+
///
155+
/// This function should only be called after all the sources were used, otherwise, the output
156+
/// will be missing the ones that are dependent on them.
157+
pub fn filter_out_ignored_sources(&mut self, filter: impl Fn(&Path) -> bool) {
158+
self.items.retain(|version, items| {
159+
items.retain(|item| {
160+
self.source_paths
161+
.get(&(version.clone(), item.loc.source_id))
162+
.map(|path| filter(path))
163+
.unwrap_or(false)
164+
});
165+
!items.is_empty()
166+
});
167+
}
153168
}
154169

155170
/// A collection of [`HitMap`]s.

crates/forge/bin/cmd/coverage.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ use foundry_config::{Config, SolcReq};
2626
use rayon::prelude::*;
2727
use rustc_hash::FxHashMap;
2828
use semver::Version;
29-
use std::{collections::HashMap, path::PathBuf, sync::Arc};
29+
use std::{
30+
collections::HashMap,
31+
path::{Path, PathBuf},
32+
sync::Arc,
33+
};
3034
use yansi::Paint;
3135

3236
// Loads project's figment and merges the build cli arguments into it
@@ -247,10 +251,8 @@ impl CoverageArgs {
247251

248252
let known_contracts = runner.known_contracts.clone();
249253

250-
let outcome = self
251-
.test
252-
.run_tests(runner, config.clone(), verbosity, &self.test.filter(&config))
253-
.await?;
254+
let filter = self.test.filter(&config);
255+
let outcome = self.test.run_tests(runner, config.clone(), verbosity, &filter).await?;
254256

255257
outcome.ensure_ok()?;
256258

@@ -288,6 +290,15 @@ impl CoverageArgs {
288290
}
289291
}
290292

293+
// Filter out ignored sources from the report
294+
let file_pattern = filter.args().coverage_pattern_inverse.as_ref();
295+
let file_root = &filter.paths().root;
296+
report.filter_out_ignored_sources(|path: &Path| {
297+
file_pattern.map_or(true, |re| {
298+
!re.is_match(&path.strip_prefix(file_root).unwrap_or(path).to_string_lossy())
299+
})
300+
});
301+
291302
// Output final report
292303
for report_kind in self.report {
293304
match report_kind {

crates/forge/bin/cmd/test/filter.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use clap::Parser;
2-
use forge::TestFilter;
2+
use foundry_common::TestFilter;
33
use foundry_compilers::{FileFilter, ProjectPathsConfig};
44
use foundry_config::{filter::GlobMatcher, Config};
55
use std::{fmt, path::Path};
@@ -38,6 +38,10 @@ pub struct FilterArgs {
3838
value_name = "GLOB"
3939
)]
4040
pub path_pattern_inverse: Option<GlobMatcher>,
41+
42+
/// Only show coverage for files that do not match the specified regex pattern.
43+
#[arg(long = "no-match-coverage", visible_alias = "nmco", value_name = "REGEX")]
44+
pub coverage_pattern_inverse: Option<regex::Regex>,
4145
}
4246

4347
impl FilterArgs {
@@ -71,6 +75,9 @@ impl FilterArgs {
7175
if self.path_pattern_inverse.is_none() {
7276
self.path_pattern_inverse = config.path_pattern_inverse.clone().map(Into::into);
7377
}
78+
if self.coverage_pattern_inverse.is_none() {
79+
self.coverage_pattern_inverse = config.coverage_pattern_inverse.clone().map(Into::into);
80+
}
7481
ProjectPathsAwareFilter { args_filter: self, paths: config.project_paths() }
7582
}
7683
}
@@ -84,6 +91,7 @@ impl fmt::Debug for FilterArgs {
8491
.field("no-match-contract", &self.contract_pattern_inverse.as_ref().map(|r| r.as_str()))
8592
.field("match-path", &self.path_pattern.as_ref().map(|g| g.as_str()))
8693
.field("no-match-path", &self.path_pattern_inverse.as_ref().map(|g| g.as_str()))
94+
.field("no-match-coverage", &self.coverage_pattern_inverse.as_ref().map(|g| g.as_str()))
8795
.finish_non_exhaustive()
8896
}
8997
}
@@ -152,6 +160,9 @@ impl fmt::Display for FilterArgs {
152160
if let Some(p) = &self.path_pattern_inverse {
153161
writeln!(f, "\tno-match-path: `{}`", p.as_str())?;
154162
}
163+
if let Some(p) = &self.coverage_pattern_inverse {
164+
writeln!(f, "\tno-match-coverage: `{}`", p.as_str())?;
165+
}
155166
Ok(())
156167
}
157168
}
@@ -178,6 +189,11 @@ impl ProjectPathsAwareFilter {
178189
pub fn args_mut(&mut self) -> &mut FilterArgs {
179190
&mut self.args_filter
180191
}
192+
193+
/// Returns the project paths.
194+
pub fn paths(&self) -> &ProjectPathsConfig {
195+
&self.paths
196+
}
181197
}
182198

183199
impl FileFilter for ProjectPathsAwareFilter {

crates/forge/tests/cli/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ forgetest!(can_extract_config_values, |prj, cmd| {
6464
contract_pattern_inverse: None,
6565
path_pattern: None,
6666
path_pattern_inverse: None,
67+
coverage_pattern_inverse: None,
6768
fuzz: FuzzConfig {
6869
runs: 1000,
6970
max_test_rejects: 100203,

crates/forge/tests/cli/coverage.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,115 @@ contract AContractTest is DSTest {
7676
};
7777
assert!(lcov_data.lines().any(valid_line), "{lcov_data}");
7878
});
79+
80+
forgetest!(test_no_match_coverage, |prj, cmd| {
81+
prj.insert_ds_test();
82+
prj.add_source(
83+
"AContract.sol",
84+
r#"
85+
contract AContract {
86+
int public i;
87+
88+
function init() public {
89+
i = 0;
90+
}
91+
92+
function foo() public {
93+
i = 1;
94+
}
95+
}
96+
"#,
97+
)
98+
.unwrap();
99+
100+
prj.add_source(
101+
"AContractTest.sol",
102+
r#"
103+
import "./test.sol";
104+
import {AContract} from "./AContract.sol";
105+
106+
contract AContractTest is DSTest {
107+
AContract a;
108+
109+
function setUp() public {
110+
a = new AContract();
111+
a.init();
112+
}
113+
114+
function testFoo() public {
115+
a.foo();
116+
}
117+
}
118+
"#,
119+
)
120+
.unwrap();
121+
122+
prj.add_source(
123+
"BContract.sol",
124+
r#"
125+
contract BContract {
126+
int public i;
127+
128+
function init() public {
129+
i = 0;
130+
}
131+
132+
function foo() public {
133+
i = 1;
134+
}
135+
}
136+
"#,
137+
)
138+
.unwrap();
139+
140+
prj.add_source(
141+
"BContractTest.sol",
142+
r#"
143+
import "./test.sol";
144+
import {BContract} from "./BContract.sol";
145+
146+
contract BContractTest is DSTest {
147+
BContract a;
148+
149+
function setUp() public {
150+
a = new BContract();
151+
a.init();
152+
}
153+
154+
function testFoo() public {
155+
a.foo();
156+
}
157+
}
158+
"#,
159+
)
160+
.unwrap();
161+
162+
let lcov_info = prj.root().join("lcov.info");
163+
cmd.arg("coverage").args([
164+
"--no-match-coverage".to_string(),
165+
"AContract".to_string(), // Filter out `AContract`
166+
"--report".to_string(),
167+
"lcov".to_string(),
168+
"--report-file".to_string(),
169+
lcov_info.to_str().unwrap().to_string(),
170+
]);
171+
cmd.assert_success();
172+
assert!(lcov_info.exists());
173+
174+
let lcov_data = std::fs::read_to_string(lcov_info).unwrap();
175+
// BContract.init must be hit at least once
176+
let re = Regex::new(r"FNDA:(\d+),BContract\.init").unwrap();
177+
let valid_line = |line| {
178+
re.captures(line)
179+
.map_or(false, |caps| caps.get(1).unwrap().as_str().parse::<i32>().unwrap() > 0)
180+
};
181+
assert!(lcov_data.lines().any(valid_line), "{lcov_data}");
182+
183+
// AContract.init must not be hit
184+
let re = Regex::new(r"FNDA:(\d+),AContract\.init").unwrap();
185+
let valid_line = |line| {
186+
re.captures(line)
187+
.map_or(false, |caps| caps.get(1).unwrap().as_str().parse::<i32>().unwrap() > 0)
188+
};
189+
assert!(!lcov_data.lines().any(valid_line), "{lcov_data}");
190+
});

crates/forge/tests/cli/test_cmd.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ forgetest!(can_set_filter_values, |prj, cmd| {
2121
contract_pattern_inverse: None,
2222
path_pattern: Some(glob.clone()),
2323
path_pattern_inverse: None,
24+
coverage_pattern_inverse: None,
2425
..Default::default()
2526
};
2627
prj.write_config(config);
@@ -33,6 +34,7 @@ forgetest!(can_set_filter_values, |prj, cmd| {
3334
assert_eq!(config.contract_pattern_inverse, None);
3435
assert_eq!(config.path_pattern.unwrap(), glob);
3536
assert_eq!(config.path_pattern_inverse, None);
37+
assert_eq!(config.coverage_pattern_inverse, None);
3638
});
3739

3840
// tests that warning is displayed when there are no tests in project

0 commit comments

Comments
 (0)