Skip to content

Commit 7a23a5c

Browse files
authored
fix(coverage): clean ups, use normalized source code for locations (#9438)
* feat(coverage): add function line end to LCOV, clean ups * fix(coverage): store normalized source code * fix(coverage): add a Line item for functions too * test: update snapshots * clean
1 parent 7f41280 commit 7a23a5c

File tree

6 files changed

+219
-189
lines changed

6 files changed

+219
-189
lines changed

crates/evm/coverage/src/analysis.rs

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use super::{CoverageItem, CoverageItemKind, SourceLocation};
22
use alloy_primitives::map::HashMap;
33
use foundry_common::TestFunctionExt;
4-
use foundry_compilers::artifacts::ast::{self, Ast, Node, NodeType};
4+
use foundry_compilers::artifacts::{
5+
ast::{self, Ast, Node, NodeType},
6+
Source,
7+
};
58
use rayon::prelude::*;
69
use std::sync::Arc;
710

@@ -19,7 +22,7 @@ pub struct ContractVisitor<'a> {
1922
/// The current branch ID
2023
branch_id: usize,
2124
/// Stores the last line we put in the items collection to ensure we don't push duplicate lines
22-
last_line: usize,
25+
last_line: u32,
2326

2427
/// Coverage items
2528
pub items: Vec<CoverageItem>,
@@ -47,23 +50,20 @@ impl<'a> ContractVisitor<'a> {
4750
}
4851

4952
fn visit_function_definition(&mut self, node: &Node) -> eyre::Result<()> {
50-
let name: String =
51-
node.attribute("name").ok_or_else(|| eyre::eyre!("Function has no name"))?;
53+
let Some(body) = &node.body else { return Ok(()) };
5254

5355
let kind: String =
5456
node.attribute("kind").ok_or_else(|| eyre::eyre!("Function has no kind"))?;
5557

56-
match &node.body {
57-
Some(body) => {
58-
// Do not add coverage item for constructors without statements.
59-
if kind == "constructor" && !has_statements(body) {
60-
return Ok(())
61-
}
62-
self.push_item_kind(CoverageItemKind::Function { name }, &node.src);
63-
self.visit_block(body)
64-
}
65-
_ => Ok(()),
58+
let name: String =
59+
node.attribute("name").ok_or_else(|| eyre::eyre!("Function has no name"))?;
60+
61+
// Do not add coverage item for constructors without statements.
62+
if kind == "constructor" && !has_statements(body) {
63+
return Ok(())
6664
}
65+
self.push_item_kind(CoverageItemKind::Function { name }, &node.src);
66+
self.visit_block(body)
6767
}
6868

6969
fn visit_modifier_or_yul_fn_definition(&mut self, node: &Node) -> eyre::Result<()> {
@@ -454,30 +454,34 @@ impl<'a> ContractVisitor<'a> {
454454
/// collection (plus additional coverage line if item is a statement).
455455
fn push_item_kind(&mut self, kind: CoverageItemKind, src: &ast::LowFidelitySourceLocation) {
456456
let item = CoverageItem { kind, loc: self.source_location_for(src), hits: 0 };
457-
// Push a line item if we haven't already
458-
if matches!(item.kind, CoverageItemKind::Statement | CoverageItemKind::Branch { .. }) &&
459-
self.last_line < item.loc.line
460-
{
457+
458+
// Push a line item if we haven't already.
459+
debug_assert!(!matches!(item.kind, CoverageItemKind::Line));
460+
if self.last_line < item.loc.lines.start {
461461
self.items.push(CoverageItem {
462462
kind: CoverageItemKind::Line,
463463
loc: item.loc.clone(),
464464
hits: 0,
465465
});
466-
self.last_line = item.loc.line;
466+
self.last_line = item.loc.lines.start;
467467
}
468468

469469
self.items.push(item);
470470
}
471471

472472
fn source_location_for(&self, loc: &ast::LowFidelitySourceLocation) -> SourceLocation {
473-
let loc_start =
474-
self.source.char_indices().map(|(i, _)| i).nth(loc.start).unwrap_or_default();
473+
let bytes_start = loc.start as u32;
474+
let bytes_end = (loc.start + loc.length.unwrap_or(0)) as u32;
475+
let bytes = bytes_start..bytes_end;
476+
477+
let start_line = self.source[..bytes.start as usize].lines().count() as u32;
478+
let n_lines = self.source[bytes.start as usize..bytes.end as usize].lines().count() as u32;
479+
let lines = start_line..start_line + n_lines;
475480
SourceLocation {
476481
source_id: self.source_id,
477482
contract_name: self.contract_name.clone(),
478-
start: loc.start as u32,
479-
length: loc.length.map(|x| x as u32),
480-
line: self.source[..loc_start].lines().count(),
483+
bytes,
484+
lines,
481485
}
482486
}
483487
}
@@ -556,7 +560,7 @@ impl<'a> SourceAnalyzer<'a> {
556560
.attribute("name")
557561
.ok_or_else(|| eyre::eyre!("Contract has no name"))?;
558562

559-
let mut visitor = ContractVisitor::new(source_id, source, &name);
563+
let mut visitor = ContractVisitor::new(source_id, &source.content, &name);
560564
visitor.visit_contract(node)?;
561565
let mut items = visitor.items;
562566

@@ -590,7 +594,7 @@ pub struct SourceFiles<'a> {
590594
#[derive(Debug)]
591595
pub struct SourceFile<'a> {
592596
/// The source code.
593-
pub source: String,
597+
pub source: Source,
594598
/// The AST of the source code.
595599
pub ast: &'a Ast,
596600
}

crates/evm/coverage/src/anchors.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,14 +177,14 @@ fn is_in_source_range(element: &SourceElement, location: &SourceLocation) -> boo
177177
}
178178

179179
// Needed because some source ranges in the source map mark the entire contract...
180-
let is_within_start = element.offset() >= location.start;
180+
let is_within_start = element.offset() >= location.bytes.start;
181181
if !is_within_start {
182182
return false;
183183
}
184184

185-
let start_of_ranges = location.start.max(element.offset());
186-
let end_of_ranges = (location.start + location.length.unwrap_or_default())
187-
.min(element.offset() + element.length());
185+
let start_of_ranges = location.bytes.start.max(element.offset());
186+
let end_of_ranges =
187+
(location.bytes.start + location.len()).min(element.offset() + element.length());
188188
let within_ranges = start_of_ranges <= end_of_ranges;
189189
if !within_ranges {
190190
return false;

crates/evm/coverage/src/lib.rs

Lines changed: 78 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use semver::Version;
1818
use std::{
1919
collections::BTreeMap,
2020
fmt::Display,
21-
ops::{AddAssign, Deref, DerefMut},
21+
ops::{Deref, DerefMut, Range},
2222
path::{Path, PathBuf},
2323
sync::Arc,
2424
};
@@ -82,40 +82,29 @@ impl CoverageReport {
8282
self.anchors.extend(anchors);
8383
}
8484

85-
/// Get coverage summaries by source file path.
86-
pub fn summary_by_file(&self) -> impl Iterator<Item = (PathBuf, CoverageSummary)> {
87-
let mut summaries = BTreeMap::new();
88-
89-
for (version, items) in self.items.iter() {
90-
for item in items {
91-
let Some(path) =
92-
self.source_paths.get(&(version.clone(), item.loc.source_id)).cloned()
93-
else {
94-
continue;
95-
};
96-
*summaries.entry(path).or_default() += item;
97-
}
98-
}
99-
100-
summaries.into_iter()
85+
/// Returns an iterator over coverage summaries by source file path.
86+
pub fn summary_by_file(&self) -> impl Iterator<Item = (&Path, CoverageSummary)> {
87+
self.by_file(|summary: &mut CoverageSummary, item| summary.add_item(item))
10188
}
10289

103-
/// Get coverage items by source file path.
104-
pub fn items_by_source(&self) -> impl Iterator<Item = (PathBuf, Vec<CoverageItem>)> {
105-
let mut items_by_source: BTreeMap<_, Vec<_>> = BTreeMap::new();
90+
/// Returns an iterator over coverage items by source file path.
91+
pub fn items_by_file(&self) -> impl Iterator<Item = (&Path, Vec<&CoverageItem>)> {
92+
self.by_file(|list: &mut Vec<_>, item| list.push(item))
93+
}
10694

107-
for (version, items) in self.items.iter() {
95+
fn by_file<'a, T: Default>(
96+
&'a self,
97+
mut f: impl FnMut(&mut T, &'a CoverageItem),
98+
) -> impl Iterator<Item = (&'a Path, T)> {
99+
let mut by_file: BTreeMap<&Path, T> = BTreeMap::new();
100+
for (version, items) in &self.items {
108101
for item in items {
109-
let Some(path) =
110-
self.source_paths.get(&(version.clone(), item.loc.source_id)).cloned()
111-
else {
112-
continue;
113-
};
114-
items_by_source.entry(path).or_default().push(item.clone());
102+
let key = (version.clone(), item.loc.source_id);
103+
let Some(path) = self.source_paths.get(&key) else { continue };
104+
f(by_file.entry(path).or_default(), item);
115105
}
116106
}
117-
118-
items_by_source.into_iter()
107+
by_file.into_iter()
119108
}
120109

121110
/// Processes data from a [`HitMap`] and sets hit counts for coverage items in this coverage
@@ -345,30 +334,34 @@ impl Display for CoverageItem {
345334
}
346335
}
347336

337+
/// A source location.
348338
#[derive(Clone, Debug)]
349339
pub struct SourceLocation {
350340
/// The source ID.
351341
pub source_id: usize,
352342
/// The contract this source range is in.
353343
pub contract_name: Arc<str>,
354-
/// Start byte in the source code.
355-
pub start: u32,
356-
/// Number of bytes in the source code.
357-
pub length: Option<u32>,
358-
/// The line in the source code.
359-
pub line: usize,
344+
/// Byte range.
345+
pub bytes: Range<u32>,
346+
/// Line range. Indices are 1-based.
347+
pub lines: Range<u32>,
360348
}
361349

362350
impl Display for SourceLocation {
363351
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364-
write!(
365-
f,
366-
"source ID {}, line {}, chars {}-{}",
367-
self.source_id,
368-
self.line,
369-
self.start,
370-
self.length.map_or(self.start, |length| self.start + length)
371-
)
352+
write!(f, "source ID {}, lines {:?}, bytes {:?}", self.source_id, self.lines, self.bytes)
353+
}
354+
}
355+
356+
impl SourceLocation {
357+
/// Returns the length of the byte range.
358+
pub fn len(&self) -> u32 {
359+
self.bytes.len() as u32
360+
}
361+
362+
/// Returns true if the byte range is empty.
363+
pub fn is_empty(&self) -> bool {
364+
self.len() == 0
372365
}
373366
}
374367

@@ -393,21 +386,43 @@ pub struct CoverageSummary {
393386
pub function_hits: usize,
394387
}
395388

396-
impl AddAssign<&Self> for CoverageSummary {
397-
fn add_assign(&mut self, other: &Self) {
398-
self.line_count += other.line_count;
399-
self.line_hits += other.line_hits;
400-
self.statement_count += other.statement_count;
401-
self.statement_hits += other.statement_hits;
402-
self.branch_count += other.branch_count;
403-
self.branch_hits += other.branch_hits;
404-
self.function_count += other.function_count;
405-
self.function_hits += other.function_hits;
389+
impl CoverageSummary {
390+
/// Creates a new, empty coverage summary.
391+
pub fn new() -> Self {
392+
Self::default()
393+
}
394+
395+
/// Creates a coverage summary from a collection of coverage items.
396+
pub fn from_items<'a>(items: impl IntoIterator<Item = &'a CoverageItem>) -> Self {
397+
let mut summary = Self::default();
398+
summary.add_items(items);
399+
summary
406400
}
407-
}
408401

409-
impl AddAssign<&CoverageItem> for CoverageSummary {
410-
fn add_assign(&mut self, item: &CoverageItem) {
402+
/// Adds another coverage summary to this one.
403+
pub fn merge(&mut self, other: &Self) {
404+
let Self {
405+
line_count,
406+
line_hits,
407+
statement_count,
408+
statement_hits,
409+
branch_count,
410+
branch_hits,
411+
function_count,
412+
function_hits,
413+
} = self;
414+
*line_count += other.line_count;
415+
*line_hits += other.line_hits;
416+
*statement_count += other.statement_count;
417+
*statement_hits += other.statement_hits;
418+
*branch_count += other.branch_count;
419+
*branch_hits += other.branch_hits;
420+
*function_count += other.function_count;
421+
*function_hits += other.function_hits;
422+
}
423+
424+
/// Adds a coverage item to this summary.
425+
pub fn add_item(&mut self, item: &CoverageItem) {
411426
match item.kind {
412427
CoverageItemKind::Line => {
413428
self.line_count += 1;
@@ -435,4 +450,11 @@ impl AddAssign<&CoverageItem> for CoverageSummary {
435450
}
436451
}
437452
}
453+
454+
/// Adds multiple coverage items to this summary.
455+
pub fn add_items<'a>(&mut self, items: impl IntoIterator<Item = &'a CoverageItem>) {
456+
for item in items {
457+
self.add_item(item);
458+
}
459+
}
438460
}

crates/forge/bin/cmd/coverage.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ use forge::{
1616
use foundry_cli::utils::{LoadConfig, STATIC_FUZZ_SEED};
1717
use foundry_common::{compile::ProjectCompiler, fs};
1818
use foundry_compilers::{
19-
artifacts::{sourcemap::SourceMap, CompactBytecode, CompactDeployedBytecode, SolcLanguage},
19+
artifacts::{
20+
sourcemap::SourceMap, CompactBytecode, CompactDeployedBytecode, SolcLanguage, Source,
21+
},
2022
Artifact, ArtifactId, Project, ProjectCompileOutput,
2123
};
2224
use foundry_config::{Config, SolcReq};
2325
use rayon::prelude::*;
2426
use semver::Version;
2527
use std::{
28+
io,
2629
path::{Path, PathBuf},
2730
sync::Arc,
2831
};
@@ -153,7 +156,7 @@ impl CoverageArgs {
153156

154157
let source = SourceFile {
155158
ast,
156-
source: fs::read_to_string(&file)
159+
source: Source::read(&file)
157160
.wrap_err("Could not read source code for analysis")?,
158161
};
159162
versioned_sources
@@ -290,19 +293,15 @@ impl CoverageArgs {
290293
match report_kind {
291294
CoverageReportKind::Summary => SummaryReporter::default().report(&report),
292295
CoverageReportKind::Lcov => {
293-
if let Some(report_file) = self.report_file {
294-
return LcovReporter::new(&mut fs::create_file(root.join(report_file))?)
295-
.report(&report)
296-
} else {
297-
return LcovReporter::new(&mut fs::create_file(root.join("lcov.info"))?)
298-
.report(&report)
299-
}
296+
let path =
297+
root.join(self.report_file.as_deref().unwrap_or("lcov.info".as_ref()));
298+
let mut file = io::BufWriter::new(fs::create_file(path)?);
299+
LcovReporter::new(&mut file).report(&report)
300300
}
301301
CoverageReportKind::Bytecode => {
302302
let destdir = root.join("bytecode-coverage");
303303
fs::create_dir_all(&destdir)?;
304-
BytecodeReporter::new(root.clone(), destdir).report(&report)?;
305-
Ok(())
304+
BytecodeReporter::new(root.clone(), destdir).report(&report)
306305
}
307306
CoverageReportKind::Debug => DebugReporter.report(&report),
308307
}?;

0 commit comments

Comments
 (0)