1use crate::result::{TestKind, TestOutcome, TestResult, TestStatus};
4use alloy_primitives::map::{HashMap, HashSet};
5use comfy_table::{
6 Attribute, Cell, Color, Row, Table,
7 presets::{ASCII_FULL, ASCII_MARKDOWN},
8};
9use evm_disassembler::disassemble_bytes;
10use foundry_common::{fs, shell};
11use semver::Version;
12use serde::{Serialize, ser::SerializeSeq};
13use std::{
14 collections::{BTreeMap, hash_map},
15 io::Write,
16 path::{Path, PathBuf},
17};
18
19pub use foundry_evm::coverage::*;
20
21pub trait CoverageReporter {
23 fn name(&self) -> &'static str;
25
26 fn needs_source_maps(&self) -> bool {
28 false
29 }
30
31 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()>;
33}
34
35pub struct CoverageSummaryReporter {
37 table: Table,
39 total: CoverageSummary,
41}
42
43impl Default for CoverageSummaryReporter {
44 fn default() -> Self {
45 let mut table = Table::new();
46 if shell::is_markdown() {
47 table.load_style(ASCII_MARKDOWN);
48 } else {
49 table.load_style(ASCII_FULL.with_rounded_corners());
50 }
51
52 table.set_header(vec![
53 Cell::new("File"),
54 Cell::new("% Lines"),
55 Cell::new("% Statements"),
56 Cell::new("% Branches"),
57 Cell::new("% Funcs"),
58 ]);
59
60 Self { table, total: CoverageSummary::new() }
61 }
62}
63
64impl CoverageSummaryReporter {
65 fn add_row(&mut self, name: impl Into<Cell>, summary: CoverageSummary) {
66 let mut row = Row::new();
67 row.add_cell(name.into())
68 .add_cell(format_cell(summary.line_hits, summary.line_count))
69 .add_cell(format_cell(summary.statement_hits, summary.statement_count))
70 .add_cell(format_cell(summary.branch_hits, summary.branch_count))
71 .add_cell(format_cell(summary.function_hits, summary.function_count));
72 self.table.add_row(row);
73 }
74}
75
76impl CoverageReporter for CoverageSummaryReporter {
77 fn name(&self) -> &'static str {
78 "summary"
79 }
80
81 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
82 for (path, summary) in report.summary_by_file() {
83 self.total.merge(&summary);
84 self.add_row(path.display(), summary);
85 }
86
87 self.add_row("Total", self.total.clone());
88 sh_println!("\n{}", self.table)?;
89 Ok(())
90 }
91}
92
93fn format_cell(hits: usize, total: usize) -> Cell {
94 if total == 0 {
95 return Cell::new(format!("N/A ({hits}/{total})"))
96 .fg(Color::Grey)
97 .add_attribute(Attribute::Dim);
98 }
99
100 let percentage = hits as f64 / total as f64;
101 Cell::new(format!("{:.2}% ({hits}/{total})", percentage * 100.)).fg(match percentage {
102 _ if percentage < 0.5 => Color::Red,
103 _ if percentage < 0.75 => Color::Yellow,
104 _ => Color::Green,
105 })
106}
107
108pub struct LcovReporter {
113 path: PathBuf,
114 version: Version,
115}
116
117impl LcovReporter {
118 pub const fn new(path: PathBuf, version: Version) -> Self {
120 Self { path, version }
121 }
122}
123
124impl CoverageReporter for LcovReporter {
125 fn name(&self) -> &'static str {
126 "lcov"
127 }
128
129 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
130 let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
131
132 let mut fn_index = 0usize;
133 for (path, items) in report.items_by_file() {
134 let summary = CoverageSummary::from_items(&items);
135
136 writeln!(out, "TN:")?;
137 writeln!(out, "SF:{}", path.display())?;
138
139 let mut line_hits: HashMap<u32, u32> = HashMap::default();
142 for item in &items {
143 if matches!(item.kind, CoverageItemKind::Line | CoverageItemKind::Statement) {
144 let line = item.loc.lines.start;
145 line_hits
146 .entry(line)
147 .and_modify(|h| *h = (*h).max(item.hits))
148 .or_insert(item.hits);
149 }
150 }
151
152 let mut recorded_lines = HashSet::new();
153
154 for item in items {
155 let line = item.loc.lines.start;
156 let end_line = item.loc.lines.end - 1;
158 let hits = item.hits;
159 match item.kind {
160 CoverageItemKind::Function { ref name } => {
161 let name = if item.loc.contract_name.is_empty() {
164 name.to_string()
165 } else {
166 format!("{}.{name}", item.loc.contract_name)
167 };
168 if self.version >= Version::new(2, 2, 0) {
169 writeln!(out, "FNL:{fn_index},{line},{end_line}")?;
171 writeln!(out, "FNA:{fn_index},{hits},{name}")?;
172 fn_index += 1;
173 } else if self.version >= Version::new(2, 0, 0) {
174 writeln!(out, "FN:{line},{end_line},{name}")?;
176 writeln!(out, "FNDA:{hits},{name}")?;
177 } else {
178 writeln!(out, "FN:{line},{name}")?;
179 writeln!(out, "FNDA:{hits},{name}")?;
180 }
181 }
182 CoverageItemKind::Line | CoverageItemKind::Statement
184 if recorded_lines.insert(line) =>
185 {
186 writeln!(out, "DA:{line},{}", line_hits[&line])?;
187 }
188 CoverageItemKind::Branch { branch_id, path_id, .. } => {
189 let line_was_hit = line_hits.get(&line).is_some_and(|&h| h > 0);
193 let hits_str = if hits > 0 {
194 hits.to_string()
195 } else if line_was_hit {
196 "0".to_string()
197 } else {
198 "-".to_string()
199 };
200 writeln!(out, "BRDA:{line},{branch_id},{path_id},{hits_str}")?;
201 }
202 _ => {}
203 }
204 }
205
206 writeln!(out, "FNF:{}", summary.function_count)?;
208 writeln!(out, "FNH:{}", summary.function_hits)?;
209
210 writeln!(out, "LF:{}", summary.line_count)?;
212 writeln!(out, "LH:{}", summary.line_hits)?;
213
214 writeln!(out, "BRF:{}", summary.branch_count)?;
216 writeln!(out, "BRH:{}", summary.branch_hits)?;
217
218 writeln!(out, "end_of_record")?;
219 }
220
221 out.flush()?;
222 sh_println!("Wrote LCOV report.")?;
223
224 Ok(())
225 }
226}
227
228pub struct CoverageAttributionReporter {
230 path: PathBuf,
231}
232
233pub struct ResolvedHitMap {
235 pub contract_id: ContractId,
236 pub is_deployed_code: bool,
237}
238
239pub type ResolvedHitMaps = alloy_primitives::map::B256HashMap<ResolvedHitMap>;
240
241impl CoverageAttributionReporter {
242 pub const fn new(path: PathBuf) -> Self {
244 Self { path }
245 }
246
247 pub fn report(
249 &self,
250 report: &CoverageReport,
251 outcome: &TestOutcome,
252 resolved_hit_maps: &ResolvedHitMaps,
253 ) -> eyre::Result<()> {
254 let payload = AttributionReport {
255 version: 1,
256 tests: AttributionTests { report, outcome, resolved_hit_maps },
257 };
258 let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
259 serde_json::to_writer(&mut out, &payload)?;
260 writeln!(out)?;
261 out.flush()?;
262
263 sh_println!("Wrote coverage attribution report.")?;
264
265 Ok(())
266 }
267}
268
269#[derive(Serialize)]
271struct AttributionReport<'a> {
272 version: u8,
273 tests: AttributionTests<'a>,
274}
275
276#[derive(Serialize)]
278struct AttributionTest {
279 suite: String,
280 test: String,
281 status: &'static str,
282 kind: &'static str,
283 covered: Vec<AttributionItem>,
284}
285
286#[derive(Serialize)]
288struct AttributionItem {
289 source: String,
290 contract: String,
291 kind: &'static str,
292 line_start: u32,
294 line_end: u32,
296 byte_start: u32,
298 byte_end: u32,
300 hits: u32,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 function: Option<String>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 branch_id: Option<u32>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 path_id: Option<u32>,
307}
308
309struct AttributionTests<'a> {
311 report: &'a CoverageReport,
312 outcome: &'a TestOutcome,
313 resolved_hit_maps: &'a ResolvedHitMaps,
314}
315
316impl Serialize for AttributionTests<'_> {
317 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
318 where
319 S: serde::Serializer,
320 {
321 let len = self.outcome.results.values().map(|suite| suite.test_results.len()).sum();
322 let mut seq = serializer.serialize_seq(Some(len))?;
323
324 for (suite, suite_result) in &self.outcome.results {
325 for (test, result) in &suite_result.test_results {
326 seq.serialize_element(&AttributionTest {
327 suite: suite.clone(),
328 test: test.clone(),
329 status: test_status_name(result.status),
330 kind: test_kind_name(&result.kind),
331 covered: attributed_items(self.report, self.resolved_hit_maps, result),
332 })?;
333 }
334 }
335
336 seq.end()
337 }
338}
339
340fn attributed_items(
341 report: &CoverageReport,
342 resolved_hit_maps: &ResolvedHitMaps,
343 result: &TestResult,
344) -> Vec<AttributionItem> {
345 type AttributionItemKey = (
346 String,
347 String,
348 &'static str,
349 u32,
350 u32,
351 u32,
352 u32,
353 Option<String>,
354 Option<u32>,
355 Option<u32>,
356 );
357
358 let mut items = BTreeMap::<AttributionItemKey, AttributionItem>::new();
359 let Some(hit_maps) = result.line_coverage.as_ref() else { return Vec::new() };
360
361 for (code_hash, map) in &hit_maps.0 {
362 let Some(resolved) = resolved_hit_maps.get(code_hash) else { continue };
363
364 for (item, hits) in
365 report.hit_items_for_hit_map(&resolved.contract_id, map, resolved.is_deployed_code)
366 {
367 let Some(source_path) =
368 report.get_source_path(&resolved.contract_id.build_id, item.loc.source_id)
369 else {
370 continue;
371 };
372
373 let source = source_path.display().to_string();
374 let contract = item.loc.contract_name.to_string();
375 let (kind, function, branch_id, path_id) = coverage_item_kind_fields(&item.kind);
376 let line_start = item.loc.lines.start;
377 let line_end = item.loc.lines.end;
378 let byte_start = item.loc.bytes.start;
379 let byte_end = item.loc.bytes.end;
380 let key = (
381 source.clone(),
382 contract.clone(),
383 kind,
384 line_start,
385 line_end,
386 byte_start,
387 byte_end,
388 function.clone(),
389 branch_id,
390 path_id,
391 );
392
393 items.entry(key).and_modify(|item| item.hits += hits).or_insert(AttributionItem {
394 source,
395 contract,
396 kind,
397 line_start,
398 line_end,
399 byte_start,
400 byte_end,
401 hits,
402 function,
403 branch_id,
404 path_id,
405 });
406 }
407 }
408
409 items.into_values().collect()
410}
411
412fn coverage_item_kind_fields(
413 kind: &CoverageItemKind,
414) -> (&'static str, Option<String>, Option<u32>, Option<u32>) {
415 match kind {
416 CoverageItemKind::Line => ("line", None, None, None),
417 CoverageItemKind::Statement => ("statement", None, None, None),
418 CoverageItemKind::Branch { branch_id, path_id, .. } => {
419 ("branch", None, Some(*branch_id), Some(*path_id))
420 }
421 CoverageItemKind::Function { name } => ("function", Some(name.to_string()), None, None),
422 }
423}
424
425const fn test_status_name(status: TestStatus) -> &'static str {
426 match status {
427 TestStatus::Success => "success",
428 TestStatus::Failure => "failure",
429 TestStatus::Skipped => "skipped",
430 }
431}
432
433const fn test_kind_name(kind: &TestKind) -> &'static str {
434 match kind {
435 TestKind::Unit { .. } => "unit",
436 TestKind::Fuzz { .. } => "fuzz",
437 TestKind::Invariant { .. } => "invariant",
438 TestKind::Table { .. } => "table",
439 TestKind::Symbolic { .. } => "symbolic",
440 TestKind::Replay { .. } => "replay",
441 }
442}
443
444pub struct DebugReporter;
446
447impl CoverageReporter for DebugReporter {
448 fn name(&self) -> &'static str {
449 "debug"
450 }
451
452 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
453 for (path, items) in report.items_by_file() {
454 let src = fs::read_to_string(path)?;
455 sh_println!("{}:", path.display())?;
456 for item in items {
457 sh_println!("- {}", item.fmt_with_source(Some(&src)))?;
458 }
459 sh_println!()?;
460 }
461
462 for (contract_id, (cta, rta)) in &report.anchors {
463 if cta.anchors.is_empty() && rta.anchors.is_empty() {
464 continue;
465 }
466
467 let anchors = cta
468 .anchors
469 .iter()
470 .map(|anchor| (false, anchor))
471 .chain(rta.anchors.iter().map(|anchor| (true, anchor)))
472 .filter_map(|(is_runtime, anchor)| {
473 let item = report
474 .analyses
475 .get(&contract_id.build_id)
476 .and_then(|items| items.get(anchor.item_id))?;
477 report
480 .get_source_path(&contract_id.build_id, item.loc.source_id)
481 .is_some()
482 .then_some((is_runtime, anchor, item))
483 })
484 .collect::<Vec<_>>();
485 if anchors.is_empty() {
486 continue;
487 }
488
489 sh_println!("Anchors for {contract_id}:")?;
490 for (is_runtime, anchor, item) in anchors {
491 let kind = if is_runtime { " runtime" } else { "creation" };
492 sh_println!("- {kind} {anchor}: {item}")?;
493 }
494 sh_println!()?;
495 }
496
497 Ok(())
498 }
499}
500
501pub struct BytecodeReporter {
502 root: PathBuf,
503 destdir: PathBuf,
504}
505
506impl BytecodeReporter {
507 pub const fn new(root: PathBuf, destdir: PathBuf) -> Self {
508 Self { root, destdir }
509 }
510}
511
512impl CoverageReporter for BytecodeReporter {
513 fn name(&self) -> &'static str {
514 "bytecode"
515 }
516
517 fn needs_source_maps(&self) -> bool {
518 true
519 }
520
521 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
522 use std::fmt::Write;
523
524 fs::create_dir_all(&self.destdir)?;
525
526 let no_source_elements = Vec::new();
527 let mut line_number_cache = LineNumberCache::new(self.root.clone());
528
529 for (contract_id, hits) in &report.bytecode_hits {
530 let ops = disassemble_bytes(hits.bytecode().to_vec())?;
531 let mut formatted = String::new();
532
533 let source_elements =
534 report.source_maps.get(contract_id).map(|sm| &sm.1).unwrap_or(&no_source_elements);
535
536 for (code, source_element) in std::iter::zip(ops.iter(), source_elements) {
537 let hits = hits
538 .get(code.offset)
539 .map(|h| format!("[{h:03}]"))
540 .unwrap_or(" ".to_owned());
541 let source_id = source_element.index();
542 let source_path = source_id
543 .and_then(|i| report.get_source_path(&contract_id.build_id, i as usize));
544
545 let code = format!("{code:?}");
546 let start = source_element.offset() as usize;
547 let end = (source_element.offset() + source_element.length()) as usize;
548
549 if let Some(source_path) = source_path {
550 let (sline, spos) = line_number_cache.get_position(source_path, start)?;
551 let (eline, epos) = line_number_cache.get_position(source_path, end)?;
552 writeln!(
553 formatted,
554 "{} {:40} // {}: {}:{}-{}:{} ({}-{})",
555 hits,
556 code,
557 source_path.display(),
558 sline,
559 spos,
560 eline,
561 epos,
562 start,
563 end
564 )?;
565 } else if let Some(source_id) = source_id {
566 writeln!(formatted, "{hits} {code:40} // SRCID{source_id}: ({start}-{end})")?;
567 } else {
568 writeln!(formatted, "{hits} {code:40}")?;
569 }
570 }
571 fs::write(
572 self.destdir.join(&*contract_id.contract_name).with_extension("asm"),
573 formatted,
574 )?;
575 }
576
577 Ok(())
578 }
579}
580
581struct LineNumberCache {
583 root: PathBuf,
584 line_offsets: HashMap<PathBuf, Vec<usize>>,
585}
586
587impl LineNumberCache {
588 pub fn new(root: PathBuf) -> Self {
589 Self { root, line_offsets: HashMap::default() }
590 }
591
592 pub fn get_position(&mut self, path: &Path, offset: usize) -> eyre::Result<(usize, usize)> {
593 let line_offsets = match self.line_offsets.entry(path.to_path_buf()) {
594 hash_map::Entry::Occupied(o) => o.into_mut(),
595 hash_map::Entry::Vacant(v) => {
596 let text = fs::read_to_string(self.root.join(path))?;
597 let mut line_offsets = vec![0];
598 for line in text.lines() {
599 let line_offset = line.as_ptr() as usize - text.as_ptr() as usize;
600 line_offsets.push(line_offset);
601 }
602 v.insert(line_offsets)
603 }
604 };
605 let lo = match line_offsets.binary_search(&offset) {
606 Ok(lo) => lo,
607 Err(lo) => lo - 1,
608 };
609 let pos = offset - line_offsets.get(lo).unwrap() + 1;
610 Ok((lo, pos))
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn empty_summary_cell_is_not_applicable() {
620 assert_eq!(
621 format_cell(0, 0),
622 Cell::new("N/A (0/0)").fg(Color::Grey).add_attribute(Attribute::Dim)
623 );
624 }
625}