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