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