Skip to main content

forge/
coverage.rs

1//! Coverage reports.
2
3use 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
20/// A coverage reporter.
21pub trait CoverageReporter {
22    /// Returns a debug string for the reporter.
23    fn name(&self) -> &'static str;
24
25    /// Returns `true` if the reporter needs source maps for the final report.
26    fn needs_source_maps(&self) -> bool {
27        false
28    }
29
30    /// Runs the reporter.
31    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()>;
32}
33
34/// A simple summary reporter that prints the coverage results in a table.
35pub struct CoverageSummaryReporter {
36    /// The summary table.
37    table: Table,
38    /// The total coverage of the entire project.
39    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
109/// Writes the coverage report in [LCOV]'s [tracefile format].
110///
111/// [LCOV]: https://github.com/linux-test-project/lcov
112/// [tracefile format]: https://man.archlinux.org/man/geninfo.1.en#TRACEFILE_FORMAT
113pub struct LcovReporter {
114    path: PathBuf,
115    version: Version,
116}
117
118impl LcovReporter {
119    /// Create a new LCOV reporter.
120    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            // First pass: collect line hits for DA records.
141            // Track both which lines have been recorded and the max hits per line.
142            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                // `lines` is half-open, so we need to subtract 1 to get the last included line.
158                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                            // v2.2 changed the FN format.
165                            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                            // v2.0 added end_line to FN.
170                            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                    // Add lines / statement hits only once.
178                    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                        // Per LCOV spec: "-" means the expression was never evaluated (line not
185                        // executed), "0" means branch exists but was never taken.
186                        // Check if the line containing this branch was hit.
187                        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            // Function summary
202            writeln!(out, "FNF:{}", summary.function_count)?;
203            writeln!(out, "FNH:{}", summary.function_hits)?;
204
205            // Line summary
206            writeln!(out, "LF:{}", summary.line_count)?;
207            writeln!(out, "LH:{}", summary.line_hits)?;
208
209            // Branch summary
210            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
223/// Writes per-test coverage attribution as JSON.
224pub struct CoverageAttributionReporter {
225    path: PathBuf,
226}
227
228/// A hit map resolved to the contract coverage metadata it belongs to.
229pub 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    /// Create a new attribution reporter.
238    pub const fn new(path: PathBuf) -> Self {
239        Self { path }
240    }
241
242    /// Writes per-test coverage attribution for the provided outcome.
243    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/// Top-level JSON payload for per-test coverage attribution.
265#[derive(Serialize)]
266struct AttributionReport<'a> {
267    version: u8,
268    tests: AttributionTests<'a>,
269}
270
271/// Coverage attributed to a single executed test.
272#[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/// A source range covered by a test, with hit counts and item metadata.
282#[derive(Serialize)]
283struct AttributionItem {
284    source: String,
285    contract: String,
286    kind: &'static str,
287    /// The start of a 1-based, half-open line range.
288    line_start: u32,
289    /// The end of a 1-based, half-open line range.
290    line_end: u32,
291    /// The start of a 0-based, half-open byte range.
292    byte_start: u32,
293    /// The end of a 0-based, half-open byte range.
294    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
304/// Serializer state for streaming attribution entries from test results.
305struct 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
440/// A super verbose reporter for debugging coverage while it is still unstable.
441pub 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                    // Source filters retain analyses to keep anchor item IDs stable, so debug
473                    // output must apply the same reportable-source filter as other reporters.
474                    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
577/// Cache line number offsets for source files
578struct 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}