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 foundry_common::{fs, shell};
10use foundry_compilers::artifacts::Source;
11use foundry_evm::core::bytecode::InstIter;
12use semver::Version;
13use serde::{Serialize, ser::SerializeSeq};
14use std::{
15 collections::{BTreeMap, hash_map},
16 io::Write,
17 path::{Path, PathBuf},
18};
19
20pub use foundry_evm::coverage::*;
21
22pub trait CoverageReporter {
24 fn name(&self) -> &'static str;
26
27 fn needs_source_maps(&self) -> bool {
29 false
30 }
31
32 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()>;
34}
35
36pub struct CoverageSummaryReporter {
38 table: Table,
40 total: CoverageSummary,
42}
43
44impl Default for CoverageSummaryReporter {
45 fn default() -> Self {
46 let mut table = Table::new();
47 if shell::is_markdown() {
48 table.load_style(ASCII_MARKDOWN);
49 } else {
50 table.load_style(ASCII_FULL.with_rounded_corners());
51 }
52
53 table.set_header(vec![
54 Cell::new("File"),
55 Cell::new("% Lines"),
56 Cell::new("% Statements"),
57 Cell::new("% Branches"),
58 Cell::new("% Funcs"),
59 ]);
60
61 Self { table, total: CoverageSummary::new() }
62 }
63}
64
65impl CoverageSummaryReporter {
66 fn add_row(&mut self, name: impl Into<Cell>, summary: CoverageSummary) {
67 let mut row = Row::new();
68 row.add_cell(name.into())
69 .add_cell(format_cell(summary.line_hits, summary.line_count))
70 .add_cell(format_cell(summary.statement_hits, summary.statement_count))
71 .add_cell(format_cell(summary.branch_hits, summary.branch_count))
72 .add_cell(format_cell(summary.function_hits, summary.function_count));
73 self.table.add_row(row);
74 }
75}
76
77impl CoverageReporter for CoverageSummaryReporter {
78 fn name(&self) -> &'static str {
79 "summary"
80 }
81
82 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
83 for (path, summary) in report.summary_by_file() {
84 self.total.merge(&summary);
85 self.add_row(path.display(), summary);
86 }
87
88 self.add_row("Total", self.total.clone());
89 sh_println!("\n{}", self.table)?;
90 Ok(())
91 }
92}
93
94fn format_cell(hits: usize, total: usize) -> Cell {
95 if total == 0 {
96 return Cell::new(format!("N/A ({hits}/{total})"))
97 .fg(Color::Grey)
98 .add_attribute(Attribute::Dim);
99 }
100
101 let percentage = hits as f64 / total as f64;
102 Cell::new(format!("{:.2}% ({hits}/{total})", percentage * 100.)).fg(match percentage {
103 _ if percentage < 0.5 => Color::Red,
104 _ if percentage < 0.75 => Color::Yellow,
105 _ => Color::Green,
106 })
107}
108
109pub struct LcovReporter {
114 path: PathBuf,
115 version: Version,
116}
117
118impl LcovReporter {
119 pub const fn new(path: PathBuf, version: Version) -> Self {
121 Self { path, version }
122 }
123}
124
125impl CoverageReporter for LcovReporter {
126 fn name(&self) -> &'static str {
127 "lcov"
128 }
129
130 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
131 let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
132
133 let mut fn_index = 0usize;
134 for (path, items) in report.items_by_file() {
135 let summary = CoverageSummary::from_items(&items);
136
137 writeln!(out, "TN:")?;
138 writeln!(out, "SF:{}", path.display())?;
139
140 let mut line_hits: HashMap<u32, u32> = HashMap::default();
143 for item in &items {
144 if matches!(item.kind, CoverageItemKind::Line | CoverageItemKind::Statement) {
145 let line = item.loc.lines.start;
146 line_hits
147 .entry(line)
148 .and_modify(|h| *h = (*h).max(item.hits))
149 .or_insert(item.hits);
150 }
151 }
152
153 let mut recorded_lines = HashSet::new();
154
155 for item in items {
156 let line = item.loc.lines.start;
157 let end_line = item.loc.lines.end - 1;
159 let hits = item.hits;
160 match item.kind {
161 CoverageItemKind::Function { ref name } => {
162 let name = if item.loc.contract_name.is_empty() {
165 name.to_string()
166 } else {
167 format!("{}.{name}", item.loc.contract_name)
168 };
169 if self.version >= Version::new(2, 2, 0) {
170 writeln!(out, "FNL:{fn_index},{line},{end_line}")?;
172 writeln!(out, "FNA:{fn_index},{hits},{name}")?;
173 fn_index += 1;
174 } else if self.version >= Version::new(2, 0, 0) {
175 writeln!(out, "FN:{line},{end_line},{name}")?;
177 writeln!(out, "FNDA:{hits},{name}")?;
178 } else {
179 writeln!(out, "FN:{line},{name}")?;
180 writeln!(out, "FNDA:{hits},{name}")?;
181 }
182 }
183 CoverageItemKind::Line | CoverageItemKind::Statement
185 if recorded_lines.insert(line) =>
186 {
187 writeln!(out, "DA:{line},{}", line_hits[&line])?;
188 }
189 CoverageItemKind::Branch { branch_id, path_id, .. } => {
190 let line_was_hit = line_hits.get(&line).is_some_and(|&h| h > 0);
194 let hits_str = if hits > 0 {
195 hits.to_string()
196 } else if line_was_hit {
197 "0".to_string()
198 } else {
199 "-".to_string()
200 };
201 writeln!(out, "BRDA:{line},{branch_id},{path_id},{hits_str}")?;
202 }
203 _ => {}
204 }
205 }
206
207 writeln!(out, "FNF:{}", summary.function_count)?;
209 writeln!(out, "FNH:{}", summary.function_hits)?;
210
211 writeln!(out, "LF:{}", summary.line_count)?;
213 writeln!(out, "LH:{}", summary.line_hits)?;
214
215 writeln!(out, "BRF:{}", summary.branch_count)?;
217 writeln!(out, "BRH:{}", summary.branch_hits)?;
218
219 writeln!(out, "end_of_record")?;
220 }
221
222 out.flush()?;
223 sh_println!("Wrote LCOV report.")?;
224
225 Ok(())
226 }
227}
228
229pub struct CoverageAttributionReporter {
231 path: PathBuf,
232}
233
234pub struct ResolvedHitMap {
236 pub contract_id: ContractId,
237 pub is_deployed_code: bool,
238}
239
240pub type ResolvedHitMaps = alloy_primitives::map::B256HashMap<ResolvedHitMap>;
241
242impl CoverageAttributionReporter {
243 pub const fn new(path: PathBuf) -> Self {
245 Self { path }
246 }
247
248 pub fn report(
250 &self,
251 report: &CoverageReport,
252 outcome: &TestOutcome,
253 resolved_hit_maps: &ResolvedHitMaps,
254 ) -> eyre::Result<()> {
255 let payload = AttributionReport {
256 version: 1,
257 tests: AttributionTests { report, outcome, resolved_hit_maps },
258 };
259 let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
260 serde_json::to_writer(&mut out, &payload)?;
261 writeln!(out)?;
262 out.flush()?;
263
264 sh_println!("Wrote coverage attribution report.")?;
265
266 Ok(())
267 }
268}
269
270#[derive(Serialize)]
272struct AttributionReport<'a> {
273 version: u8,
274 tests: AttributionTests<'a>,
275}
276
277#[derive(Serialize)]
279struct AttributionTest {
280 suite: String,
281 test: String,
282 status: &'static str,
283 kind: &'static str,
284 covered: Vec<AttributionItem>,
285}
286
287#[derive(Serialize)]
289struct AttributionItem {
290 source: String,
291 contract: String,
292 kind: &'static str,
293 line_start: u32,
295 line_end: u32,
297 byte_start: u32,
299 byte_end: u32,
301 hits: u32,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 function: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 branch_id: Option<u32>,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 path_id: Option<u32>,
308}
309
310struct AttributionTests<'a> {
312 report: &'a CoverageReport,
313 outcome: &'a TestOutcome,
314 resolved_hit_maps: &'a ResolvedHitMaps,
315}
316
317impl Serialize for AttributionTests<'_> {
318 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
319 where
320 S: serde::Serializer,
321 {
322 let len = self.outcome.results.values().map(|suite| suite.test_results.len()).sum();
323 let mut seq = serializer.serialize_seq(Some(len))?;
324
325 for (suite, suite_result) in &self.outcome.results {
326 for (test, result) in &suite_result.test_results {
327 seq.serialize_element(&AttributionTest {
328 suite: suite.clone(),
329 test: test.clone(),
330 status: test_status_name(result.status),
331 kind: test_kind_name(&result.kind),
332 covered: attributed_items(self.report, self.resolved_hit_maps, result),
333 })?;
334 }
335 }
336
337 seq.end()
338 }
339}
340
341fn attributed_items(
342 report: &CoverageReport,
343 resolved_hit_maps: &ResolvedHitMaps,
344 result: &TestResult,
345) -> Vec<AttributionItem> {
346 type AttributionItemKey = (
347 String,
348 String,
349 &'static str,
350 u32,
351 u32,
352 u32,
353 u32,
354 Option<String>,
355 Option<u32>,
356 Option<u32>,
357 );
358
359 let mut items = BTreeMap::<AttributionItemKey, AttributionItem>::new();
360 let Some(hit_maps) = result.line_coverage.as_ref() else { return Vec::new() };
361
362 for (code_hash, map) in &hit_maps.0 {
363 let Some(resolved) = resolved_hit_maps.get(code_hash) else { continue };
364
365 for (item, hits) in
366 report.hit_items_for_hit_map(&resolved.contract_id, map, resolved.is_deployed_code)
367 {
368 let Some(source_path) =
369 report.get_source_path(&resolved.contract_id.build_id, item.loc.source_id)
370 else {
371 continue;
372 };
373
374 let source = source_path.display().to_string();
375 let contract = item.loc.contract_name.to_string();
376 let (kind, function, branch_id, path_id) = coverage_item_kind_fields(&item.kind);
377 let line_start = item.loc.lines.start;
378 let line_end = item.loc.lines.end;
379 let byte_start = item.loc.bytes.start;
380 let byte_end = item.loc.bytes.end;
381 let key = (
382 source.clone(),
383 contract.clone(),
384 kind,
385 line_start,
386 line_end,
387 byte_start,
388 byte_end,
389 function.clone(),
390 branch_id,
391 path_id,
392 );
393
394 items.entry(key).and_modify(|item| item.hits += hits).or_insert(AttributionItem {
395 source,
396 contract,
397 kind,
398 line_start,
399 line_end,
400 byte_start,
401 byte_end,
402 hits,
403 function,
404 branch_id,
405 path_id,
406 });
407 }
408 }
409
410 items.into_values().collect()
411}
412
413fn coverage_item_kind_fields(
414 kind: &CoverageItemKind,
415) -> (&'static str, Option<String>, Option<u32>, Option<u32>) {
416 match kind {
417 CoverageItemKind::Line => ("line", None, None, None),
418 CoverageItemKind::Statement => ("statement", None, None, None),
419 CoverageItemKind::Branch { branch_id, path_id, .. } => {
420 ("branch", None, Some(*branch_id), Some(*path_id))
421 }
422 CoverageItemKind::Function { name } => ("function", Some(name.to_string()), None, None),
423 }
424}
425
426const fn test_status_name(status: TestStatus) -> &'static str {
427 match status {
428 TestStatus::Success => "success",
429 TestStatus::Failure => "failure",
430 TestStatus::Skipped => "skipped",
431 }
432}
433
434const fn test_kind_name(kind: &TestKind) -> &'static str {
435 match kind {
436 TestKind::Unit { .. } => "unit",
437 TestKind::Fuzz { .. } => "fuzz",
438 TestKind::Invariant { .. } => "invariant",
439 TestKind::Table { .. } => "table",
440 TestKind::Symbolic { .. } => "symbolic",
441 TestKind::Replay { .. } => "replay",
442 }
443}
444
445pub struct DebugReporter;
447
448impl CoverageReporter for DebugReporter {
449 fn name(&self) -> &'static str {
450 "debug"
451 }
452
453 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
454 for (path, items) in report.items_by_file() {
455 let src = fs::read_to_string(path)?;
456 sh_println!("{}:", path.display())?;
457 for item in items {
458 sh_println!("- {}", item.fmt_with_source(Some(&src)))?;
459 }
460 sh_println!()?;
461 }
462
463 for (contract_id, (cta, rta)) in &report.anchors {
464 if cta.anchors.is_empty() && rta.anchors.is_empty() {
465 continue;
466 }
467
468 let anchors = cta
469 .anchors
470 .iter()
471 .map(|anchor| (false, anchor))
472 .chain(rta.anchors.iter().map(|anchor| (true, anchor)))
473 .filter_map(|(is_runtime, anchor)| {
474 let item = report
475 .analyses
476 .get(&contract_id.build_id)
477 .and_then(|items| items.get(anchor.item_id))?;
478 report
481 .get_source_path(&contract_id.build_id, item.loc.source_id)
482 .is_some()
483 .then_some((is_runtime, anchor, item))
484 })
485 .collect::<Vec<_>>();
486 if anchors.is_empty() {
487 continue;
488 }
489
490 sh_println!("Anchors for {contract_id}:")?;
491 for (is_runtime, anchor, item) in anchors {
492 let kind = if is_runtime { " runtime" } else { "creation" };
493 sh_println!("- {kind} {anchor}: {item}")?;
494 }
495 sh_println!()?;
496 }
497
498 Ok(())
499 }
500}
501
502pub struct BytecodeReporter {
503 root: PathBuf,
504 destdir: PathBuf,
505}
506
507impl BytecodeReporter {
508 pub const fn new(root: PathBuf, destdir: PathBuf) -> Self {
509 Self { root, destdir }
510 }
511}
512
513impl CoverageReporter for BytecodeReporter {
514 fn name(&self) -> &'static str {
515 "bytecode"
516 }
517
518 fn needs_source_maps(&self) -> bool {
519 true
520 }
521
522 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
523 use std::fmt::Write;
524
525 fs::create_dir_all(&self.destdir)?;
526
527 let no_source_elements = Vec::new();
528 let mut line_number_cache = LineNumberCache::new(self.root.clone());
529
530 for (contract_id, hits) in &report.bytecode_hits {
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 let ops = InstIter::new(hits.bytecode()).with_pc();
537 for ((pc, inst), source_element) in std::iter::zip(ops, source_elements) {
538 let hits =
539 hits.get(pc as u32).map(|h| format!("[{h:03}]")).unwrap_or(" ".to_owned());
540 let source_id = source_element.index();
541 let source_path = source_id
542 .and_then(|i| report.get_source_path(&contract_id.build_id, i as usize));
543
544 let code = format!("{pc:08x}: {inst}");
545 let start = source_element.offset() as usize;
546 let end = (source_element.offset() + source_element.length()) as usize;
547
548 if let Some(source_path) = source_path {
549 let (sline, spos) = line_number_cache.get_position(source_path, start)?;
550 let (eline, epos) = line_number_cache.get_position(source_path, end)?;
551 writeln!(
552 formatted,
553 "{} {:40} // {}: {}:{}-{}:{} ({}-{})",
554 hits,
555 code,
556 source_path.display(),
557 sline,
558 spos,
559 eline,
560 epos,
561 start,
562 end
563 )?;
564 } else if let Some(source_id) = source_id {
565 writeln!(formatted, "{hits} {code:40} // SRCID{source_id}: ({start}-{end})")?;
566 } else {
567 writeln!(formatted, "{hits} {code:40}")?;
568 }
569 }
570 fs::write(
571 self.destdir.join(&*contract_id.contract_name).with_extension("asm"),
572 formatted,
573 )?;
574 }
575
576 Ok(())
577 }
578}
579
580struct LineNumberCache {
582 root: PathBuf,
583 line_offsets: HashMap<PathBuf, Vec<usize>>,
584}
585
586impl LineNumberCache {
587 pub fn new(root: PathBuf) -> Self {
588 Self { root, line_offsets: HashMap::default() }
589 }
590
591 pub fn get_position(&mut self, path: &Path, offset: usize) -> eyre::Result<(usize, usize)> {
592 let line_offsets = match self.line_offsets.entry(path.to_path_buf()) {
593 hash_map::Entry::Occupied(o) => o.into_mut(),
594 hash_map::Entry::Vacant(v) => {
595 let text = fs::read_to_string(self.root.join(path))?;
596 let mut line_offsets = vec![0];
597 for line in text.lines() {
598 let line_offset = line.as_ptr() as usize - text.as_ptr() as usize;
599 line_offsets.push(line_offset);
600 }
601 v.insert(line_offsets)
602 }
603 };
604 let lo = match line_offsets.binary_search(&offset) {
605 Ok(lo) => lo,
606 Err(lo) => lo - 1,
607 };
608 let pos = offset - line_offsets.get(lo).unwrap() + 1;
609 Ok((lo, pos))
610 }
611}
612
613pub struct GapsReporter {
615 root: PathBuf,
616 path: PathBuf,
617}
618
619impl GapsReporter {
620 pub const fn new(root: PathBuf, path: PathBuf) -> Self {
622 Self { root, path }
623 }
624}
625
626impl CoverageReporter for GapsReporter {
627 fn name(&self) -> &'static str {
628 "gaps"
629 }
630
631 fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
632 let mut files = BTreeMap::new();
633 for (path, items) in report.items_by_file() {
634 if items.iter().all(|item| item.hits > 0) {
635 continue;
636 }
637
638 let src = Source::read(&self.root.join(path))?.content;
640 let functions = items
641 .iter()
642 .filter_map(|item| match &item.kind {
643 CoverageItemKind::Function { name } => Some((&item.loc.bytes, name)),
644 _ => None,
645 })
646 .collect::<Vec<_>>();
647 let mut uncovered = items
648 .iter()
649 .filter(|item| item.hits == 0)
650 .map(|item| {
651 let (kind, name, branch_id, path_id) = coverage_item_kind_fields(&item.kind);
652 let function = name.or_else(|| {
653 functions
654 .iter()
655 .find(|(bytes, _)| {
656 bytes.start <= item.loc.bytes.start
657 && item.loc.bytes.end <= bytes.end
658 })
659 .map(|(_, name)| name.to_string())
660 });
661 let snippet = src
662 .get(item.loc.bytes())
663 .and_then(|text| text.lines().next())
664 .unwrap_or_default()
665 .trim_end();
666 let (line_end, byte_end) = if matches!(item.kind, CoverageItemKind::Line) {
669 (item.loc.lines.start, item.loc.bytes.start + snippet.len() as u32)
670 } else {
671 (item.loc.lines.end - 1, item.loc.bytes.end)
672 };
673 GapItem {
674 kind,
675 contract: item.loc.contract_name.to_string(),
676 function,
677 line_start: item.loc.lines.start,
678 line_end,
679 byte_start: item.loc.bytes.start,
680 byte_end,
681 snippet: snippet.to_string(),
682 branch_id,
683 path_id,
684 }
685 })
686 .collect::<Vec<_>>();
687 let lines = uncovered
689 .iter()
690 .filter(|item| item.kind == "line")
691 .map(|item| (item.byte_start, item.byte_end))
692 .collect::<HashSet<_>>();
693 uncovered.retain(|item| {
694 item.kind != "statement" || !lines.contains(&(item.byte_start, item.byte_end))
695 });
696 files.insert(path, uncovered);
697 }
698
699 let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
700 serde_json::to_writer_pretty(&mut out, &GapsReport { version: 1, files })?;
701 writeln!(out)?;
702 out.flush()?;
703
704 sh_println!("Wrote coverage gaps report.")?;
705
706 Ok(())
707 }
708}
709
710#[derive(Serialize)]
712struct GapsReport<'a> {
713 version: u8,
714 files: BTreeMap<&'a Path, Vec<GapItem>>,
715}
716
717#[derive(Serialize)]
719struct GapItem {
720 kind: &'static str,
721 contract: String,
722 #[serde(skip_serializing_if = "Option::is_none")]
724 function: Option<String>,
725 line_start: u32,
727 line_end: u32,
729 byte_start: u32,
731 byte_end: u32,
733 snippet: String,
735 #[serde(skip_serializing_if = "Option::is_none")]
736 branch_id: Option<u32>,
737 #[serde(skip_serializing_if = "Option::is_none")]
738 path_id: Option<u32>,
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn empty_summary_cell_is_not_applicable() {
747 assert_eq!(
748 format_cell(0, 0),
749 Cell::new("N/A (0/0)").fg(Color::Grey).add_attribute(Attribute::Dim)
750 );
751 }
752}