Skip to content

Commit ca7e2c8

Browse files
authored
fix(coverage): disambiguate duplicate function names (#11188)
1 parent dd317d3 commit ca7e2c8

File tree

3 files changed

+126
-35
lines changed

3 files changed

+126
-35
lines changed

crates/evm/coverage/src/analysis.rs

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::sync::Arc;
1010

1111
/// A visitor that walks the AST of a single contract and finds coverage items.
1212
#[derive(Clone, Debug)]
13-
pub struct ContractVisitor<'a> {
13+
struct ContractVisitor<'a> {
1414
/// The source ID of the contract.
1515
source_id: u32,
1616
/// The source code that contains the AST being walked.
@@ -25,11 +25,11 @@ pub struct ContractVisitor<'a> {
2525
last_line: u32,
2626

2727
/// Coverage items
28-
pub items: Vec<CoverageItem>,
28+
items: Vec<CoverageItem>,
2929
}
3030

3131
impl<'a> ContractVisitor<'a> {
32-
pub fn new(source_id: usize, source: &'a str, contract_name: &'a Arc<str>) -> Self {
32+
fn new(source_id: usize, source: &'a str, contract_name: &'a Arc<str>) -> Self {
3333
Self {
3434
source_id: source_id.try_into().expect("too many sources"),
3535
source,
@@ -40,7 +40,46 @@ impl<'a> ContractVisitor<'a> {
4040
}
4141
}
4242

43-
pub fn visit_contract(&mut self, node: &Node) -> eyre::Result<()> {
43+
/// Filter out all items if the contract has any test functions.
44+
fn clear_if_test(&mut self) {
45+
let has_tests = self.items.iter().any(|item| {
46+
if let CoverageItemKind::Function { name } = &item.kind {
47+
name.is_any_test()
48+
} else {
49+
false
50+
}
51+
});
52+
if has_tests {
53+
self.items = Vec::new();
54+
}
55+
}
56+
57+
/// Disambiguate functions with the same name in the same contract.
58+
fn disambiguate_functions(&mut self) {
59+
if self.items.is_empty() {
60+
return;
61+
}
62+
63+
let mut dups = HashMap::<_, Vec<usize>>::default();
64+
for (i, item) in self.items.iter().enumerate() {
65+
if let CoverageItemKind::Function { name } = &item.kind {
66+
dups.entry(name.clone()).or_default().push(i);
67+
}
68+
}
69+
for dups in dups.values() {
70+
if dups.len() > 1 {
71+
for (i, &dup) in dups.iter().enumerate() {
72+
let item = &mut self.items[dup];
73+
if let CoverageItemKind::Function { name } = &item.kind {
74+
item.kind =
75+
CoverageItemKind::Function { name: format!("{name}.{i}").into() };
76+
}
77+
}
78+
}
79+
}
80+
}
81+
82+
fn visit_contract(&mut self, node: &Node) -> eyre::Result<()> {
4483
// Find all functions and walk their AST
4584
for node in &node.nodes {
4685
match node.node_type {
@@ -59,14 +98,14 @@ impl<'a> ContractVisitor<'a> {
5998
fn visit_function_definition(&mut self, node: &Node) -> eyre::Result<()> {
6099
let Some(body) = &node.body else { return Ok(()) };
61100

62-
let name: String =
101+
let name: Box<str> =
63102
node.attribute("name").ok_or_else(|| eyre::eyre!("Function has no name"))?;
64-
let kind: String =
103+
let kind: Box<str> =
65104
node.attribute("kind").ok_or_else(|| eyre::eyre!("Function has no kind"))?;
66105

67106
// TODO: We currently can only detect empty bodies in normal functions, not any of the other
68107
// kinds: https://github.com/foundry-rs/foundry/issues/9458
69-
if kind != "function" && !has_statements(body) {
108+
if &*kind != "function" && !has_statements(body) {
70109
return Ok(());
71110
}
72111

@@ -79,16 +118,12 @@ impl<'a> ContractVisitor<'a> {
79118
}
80119

81120
fn visit_modifier_or_yul_fn_definition(&mut self, node: &Node) -> eyre::Result<()> {
82-
let name: String =
83-
node.attribute("name").ok_or_else(|| eyre::eyre!("Modifier has no name"))?;
121+
let Some(body) = &node.body else { return Ok(()) };
84122

85-
match &node.body {
86-
Some(body) => {
87-
self.push_item_kind(CoverageItemKind::Function { name }, &node.src);
88-
self.visit_block(body)
89-
}
90-
_ => Ok(()),
91-
}
123+
let name: Box<str> =
124+
node.attribute("name").ok_or_else(|| eyre::eyre!("Modifier has no name"))?;
125+
self.push_item_kind(CoverageItemKind::Function { name }, &node.src);
126+
self.visit_block(body)
92127
}
93128

94129
fn visit_block(&mut self, node: &Node) -> eyre::Result<()> {
@@ -571,6 +606,7 @@ impl SourceAnalysis {
571606
/// Note: Source IDs are only unique per compilation job; that is, a code base compiled with
572607
/// two different solc versions will produce overlapping source IDs if the compiler version is
573608
/// not taken into account.
609+
#[instrument(name = "SourceAnalysis::new", skip_all)]
574610
pub fn new(data: &SourceFiles<'_>) -> eyre::Result<Self> {
575611
let mut sourced_items = data
576612
.sources
@@ -592,23 +628,12 @@ impl SourceAnalysis {
592628
let name = node
593629
.attribute("name")
594630
.ok_or_else(|| eyre::eyre!("Contract has no name"))?;
595-
631+
let _guard = debug_span!("visit_contract", %name).entered();
596632
let mut visitor = ContractVisitor::new(source_id, &source.content, &name);
597633
visitor.visit_contract(node)?;
598-
let mut items = visitor.items;
599-
600-
let is_test = items.iter().any(|item| {
601-
if let CoverageItemKind::Function { name } = &item.kind {
602-
name.is_any_test()
603-
} else {
604-
false
605-
}
606-
});
607-
if is_test {
608-
items.clear();
609-
}
610-
611-
Ok(items)
634+
visitor.clear_if_test();
635+
visitor.disambiguate_functions();
636+
Ok(visitor.items)
612637
});
613638
items.map(move |items| items.map(|items| (source_id, items)))
614639
})

crates/evm/coverage/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ pub enum CoverageItemKind {
326326
/// A function in the code.
327327
Function {
328328
/// The name of the function.
329-
name: String,
329+
name: Box<str>,
330330
},
331331
}
332332

crates/forge/tests/cli/coverage.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ use foundry_test_utils::{
55
};
66
use std::path::Path;
77

8+
#[track_caller]
9+
fn assert_lcov(cmd: &mut TestCommand, data: impl IntoData) {
10+
cmd.args(["--report=lcov", "--report-file"]).assert_file(data.into_data());
11+
}
12+
813
fn basic_base(prj: TestProject, mut cmd: TestCommand) {
914
cmd.args(["coverage", "--report=lcov", "--report=summary"]).assert_success().stdout_eq(str![[
1015
r#"
@@ -1911,7 +1916,68 @@ end_of_record
19111916
);
19121917
});
19131918

1914-
#[track_caller]
1915-
fn assert_lcov(cmd: &mut TestCommand, data: impl IntoData) {
1916-
cmd.args(["--report=lcov", "--report-file"]).assert_file(data.into_data());
1919+
// <https://github.com/foundry-rs/foundry/issues/11183>
1920+
// Test that overridden functions are disambiguated in the LCOV report.
1921+
forgetest!(disambiguate_functions, |prj, cmd| {
1922+
prj.insert_ds_test();
1923+
prj.add_source(
1924+
"Counter.sol",
1925+
r#"
1926+
contract Counter {
1927+
uint256 public number;
1928+
1929+
function increment() public {
1930+
number++;
1931+
}
1932+
function increment(uint256 amount) public {
1933+
number += amount;
1934+
}
1935+
}
1936+
"#,
1937+
)
1938+
.unwrap();
1939+
1940+
prj.add_source(
1941+
"Counter.t.sol",
1942+
r#"
1943+
import "./test.sol";
1944+
import "./Counter.sol";
1945+
1946+
contract CounterTest is DSTest {
1947+
function test_overridden() public {
1948+
Counter counter = new Counter();
1949+
counter.increment();
1950+
counter.increment(1);
1951+
counter.increment(2);
1952+
counter.increment(3);
1953+
assertEq(counter.number(), 7);
1954+
}
19171955
}
1956+
"#,
1957+
)
1958+
.unwrap();
1959+
1960+
assert_lcov(
1961+
cmd.arg("coverage"),
1962+
str![[r#"
1963+
TN:
1964+
SF:src/Counter.sol
1965+
DA:7,1
1966+
FN:7,Counter.increment.0
1967+
FNDA:1,Counter.increment.0
1968+
DA:8,1
1969+
DA:10,3
1970+
FN:10,Counter.increment.1
1971+
FNDA:3,Counter.increment.1
1972+
DA:11,3
1973+
FNF:2
1974+
FNH:2
1975+
LF:4
1976+
LH:4
1977+
BRF:0
1978+
BRH:0
1979+
end_of_record
1980+
1981+
"#]],
1982+
);
1983+
});

0 commit comments

Comments
 (0)