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,
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
21/// A coverage reporter.
22pub trait CoverageReporter {
23    /// Returns a debug string for the reporter.
24    fn name(&self) -> &'static str;
25
26    /// Returns `true` if the reporter needs source maps for the final report.
27    fn needs_source_maps(&self) -> bool {
28        false
29    }
30
31    /// Runs the reporter.
32    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()>;
33}
34
35/// A simple summary reporter that prints the coverage results in a table.
36pub struct CoverageSummaryReporter {
37    /// The summary table.
38    table: Table,
39    /// The total coverage of the entire project.
40    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::default() }
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
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn empty_summary_cell_is_not_applicable() {
114        assert_eq!(
115            format_cell(0, 0),
116            Cell::new("N/A (0/0)").fg(Color::Grey).add_attribute(Attribute::Dim)
117        );
118    }
119}
120
121/// Writes the coverage report in [LCOV]'s [tracefile format].
122///
123/// [LCOV]: https://github.com/linux-test-project/lcov
124/// [tracefile format]: https://man.archlinux.org/man/geninfo.1.en#TRACEFILE_FORMAT
125pub struct LcovReporter {
126    path: PathBuf,
127    version: Version,
128}
129
130impl LcovReporter {
131    /// Create a new LCOV reporter.
132    pub const fn new(path: PathBuf, version: Version) -> Self {
133        Self { path, version }
134    }
135}
136
137impl CoverageReporter for LcovReporter {
138    fn name(&self) -> &'static str {
139        "lcov"
140    }
141
142    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
143        let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
144
145        let mut fn_index = 0usize;
146        for (path, items) in report.items_by_file() {
147            let summary = CoverageSummary::from_items(&items);
148
149            writeln!(out, "TN:")?;
150            writeln!(out, "SF:{}", path.display())?;
151
152            // First pass: collect line hits for DA records.
153            // Track both which lines have been recorded and the max hits per line.
154            let mut line_hits: HashMap<u32, u32> = HashMap::default();
155            for item in &items {
156                if matches!(item.kind, CoverageItemKind::Line | CoverageItemKind::Statement) {
157                    let line = item.loc.lines.start;
158                    line_hits
159                        .entry(line)
160                        .and_modify(|h| *h = (*h).max(item.hits))
161                        .or_insert(item.hits);
162                }
163            }
164
165            let mut recorded_lines = HashSet::new();
166
167            for item in items {
168                let line = item.loc.lines.start;
169                // `lines` is half-open, so we need to subtract 1 to get the last included line.
170                let end_line = item.loc.lines.end - 1;
171                let hits = item.hits;
172                match item.kind {
173                    CoverageItemKind::Function { ref name } => {
174                        // Free (file-level) functions have no contract scope; emit the bare
175                        // name rather than a leading-dot `.name`.
176                        let name = if item.loc.contract_name.is_empty() {
177                            name.to_string()
178                        } else {
179                            format!("{}.{name}", item.loc.contract_name)
180                        };
181                        if self.version >= Version::new(2, 2, 0) {
182                            // v2.2 changed the FN format.
183                            writeln!(out, "FNL:{fn_index},{line},{end_line}")?;
184                            writeln!(out, "FNA:{fn_index},{hits},{name}")?;
185                            fn_index += 1;
186                        } else if self.version >= Version::new(2, 0, 0) {
187                            // v2.0 added end_line to FN.
188                            writeln!(out, "FN:{line},{end_line},{name}")?;
189                            writeln!(out, "FNDA:{hits},{name}")?;
190                        } else {
191                            writeln!(out, "FN:{line},{name}")?;
192                            writeln!(out, "FNDA:{hits},{name}")?;
193                        }
194                    }
195                    // Add lines / statement hits only once.
196                    CoverageItemKind::Line | CoverageItemKind::Statement
197                        if recorded_lines.insert(line) =>
198                    {
199                        writeln!(out, "DA:{line},{}", line_hits[&line])?;
200                    }
201                    CoverageItemKind::Branch { branch_id, path_id, .. } => {
202                        // Per LCOV spec: "-" means the expression was never evaluated (line not
203                        // executed), "0" means branch exists but was never taken.
204                        // Check if the line containing this branch was hit.
205                        let line_was_hit = line_hits.get(&line).is_some_and(|&h| h > 0);
206                        let hits_str = if hits > 0 {
207                            hits.to_string()
208                        } else if line_was_hit {
209                            "0".to_string()
210                        } else {
211                            "-".to_string()
212                        };
213                        writeln!(out, "BRDA:{line},{branch_id},{path_id},{hits_str}")?;
214                    }
215                    _ => {}
216                }
217            }
218
219            // Function summary
220            writeln!(out, "FNF:{}", summary.function_count)?;
221            writeln!(out, "FNH:{}", summary.function_hits)?;
222
223            // Line summary
224            writeln!(out, "LF:{}", summary.line_count)?;
225            writeln!(out, "LH:{}", summary.line_hits)?;
226
227            // Branch summary
228            writeln!(out, "BRF:{}", summary.branch_count)?;
229            writeln!(out, "BRH:{}", summary.branch_hits)?;
230
231            writeln!(out, "end_of_record")?;
232        }
233
234        out.flush()?;
235        sh_println!("Wrote LCOV report.")?;
236
237        Ok(())
238    }
239}
240
241/// Writes per-test coverage attribution as JSON.
242pub struct CoverageAttributionReporter {
243    path: PathBuf,
244}
245
246/// A hit map resolved to the contract coverage metadata it belongs to.
247pub struct ResolvedHitMap {
248    pub contract_id: ContractId,
249    pub is_deployed_code: bool,
250}
251
252pub type ResolvedHitMaps = alloy_primitives::map::B256HashMap<ResolvedHitMap>;
253
254impl CoverageAttributionReporter {
255    /// Create a new attribution reporter.
256    pub const fn new(path: PathBuf) -> Self {
257        Self { path }
258    }
259
260    /// Writes per-test coverage attribution for the provided outcome.
261    pub fn report(
262        &self,
263        report: &CoverageReport,
264        outcome: &TestOutcome,
265        resolved_hit_maps: &ResolvedHitMaps,
266    ) -> eyre::Result<()> {
267        let payload = AttributionReport {
268            version: 1,
269            tests: AttributionTests { report, outcome, resolved_hit_maps },
270        };
271        let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
272        serde_json::to_writer(&mut out, &payload)?;
273        writeln!(out)?;
274        out.flush()?;
275
276        sh_println!("Wrote coverage attribution report.")?;
277
278        Ok(())
279    }
280}
281
282/// Top-level JSON payload for per-test coverage attribution.
283#[derive(Serialize)]
284struct AttributionReport<'a> {
285    version: u8,
286    tests: AttributionTests<'a>,
287}
288
289/// Coverage attributed to a single executed test.
290#[derive(Serialize)]
291struct AttributionTest {
292    suite: String,
293    test: String,
294    status: &'static str,
295    kind: &'static str,
296    covered: Vec<AttributionItem>,
297}
298
299/// A source range covered by a test, with hit counts and item metadata.
300#[derive(Serialize)]
301struct AttributionItem {
302    source: String,
303    contract: String,
304    kind: &'static str,
305    /// The start of a 1-based, half-open line range.
306    line_start: u32,
307    /// The end of a 1-based, half-open line range.
308    line_end: u32,
309    /// The start of a 0-based, half-open byte range.
310    byte_start: u32,
311    /// The end of a 0-based, half-open byte range.
312    byte_end: u32,
313    hits: u32,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    function: Option<String>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    branch_id: Option<u32>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    path_id: Option<u32>,
320}
321
322/// Serializer state for streaming attribution entries from test results.
323struct AttributionTests<'a> {
324    report: &'a CoverageReport,
325    outcome: &'a TestOutcome,
326    resolved_hit_maps: &'a ResolvedHitMaps,
327}
328
329impl Serialize for AttributionTests<'_> {
330    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
331    where
332        S: serde::Serializer,
333    {
334        let len = self.outcome.results.values().map(|suite| suite.test_results.len()).sum();
335        let mut seq = serializer.serialize_seq(Some(len))?;
336
337        for (suite, suite_result) in &self.outcome.results {
338            for (test, result) in &suite_result.test_results {
339                seq.serialize_element(&AttributionTest {
340                    suite: suite.clone(),
341                    test: test.clone(),
342                    status: test_status_name(result.status),
343                    kind: test_kind_name(&result.kind),
344                    covered: attributed_items(self.report, self.resolved_hit_maps, result),
345                })?;
346            }
347        }
348
349        seq.end()
350    }
351}
352
353fn attributed_items(
354    report: &CoverageReport,
355    resolved_hit_maps: &ResolvedHitMaps,
356    result: &TestResult,
357) -> Vec<AttributionItem> {
358    type AttributionItemKey = (
359        String,
360        String,
361        &'static str,
362        u32,
363        u32,
364        u32,
365        u32,
366        Option<String>,
367        Option<u32>,
368        Option<u32>,
369    );
370
371    let mut items = BTreeMap::<AttributionItemKey, AttributionItem>::new();
372    let Some(hit_maps) = result.line_coverage.as_ref() else { return Vec::new() };
373
374    for (code_hash, map) in &hit_maps.0 {
375        let Some(resolved) = resolved_hit_maps.get(code_hash) else { continue };
376
377        for (item, hits) in
378            report.hit_items_for_hit_map(&resolved.contract_id, map, resolved.is_deployed_code)
379        {
380            let Some(source_path) =
381                report.get_source_path(&resolved.contract_id.build_id, item.loc.source_id)
382            else {
383                continue;
384            };
385
386            let source = source_path.display().to_string();
387            let contract = item.loc.contract_name.to_string();
388            let (kind, function, branch_id, path_id) = coverage_item_kind_fields(&item.kind);
389            let line_start = item.loc.lines.start;
390            let line_end = item.loc.lines.end;
391            let byte_start = item.loc.bytes.start;
392            let byte_end = item.loc.bytes.end;
393            let key = (
394                source.clone(),
395                contract.clone(),
396                kind,
397                line_start,
398                line_end,
399                byte_start,
400                byte_end,
401                function.clone(),
402                branch_id,
403                path_id,
404            );
405
406            items.entry(key).and_modify(|item| item.hits += hits).or_insert(AttributionItem {
407                source,
408                contract,
409                kind,
410                line_start,
411                line_end,
412                byte_start,
413                byte_end,
414                hits,
415                function,
416                branch_id,
417                path_id,
418            });
419        }
420    }
421
422    items.into_values().collect()
423}
424
425fn coverage_item_kind_fields(
426    kind: &CoverageItemKind,
427) -> (&'static str, Option<String>, Option<u32>, Option<u32>) {
428    match kind {
429        CoverageItemKind::Line => ("line", None, None, None),
430        CoverageItemKind::Statement => ("statement", None, None, None),
431        CoverageItemKind::Branch { branch_id, path_id, .. } => {
432            ("branch", None, Some(*branch_id), Some(*path_id))
433        }
434        CoverageItemKind::Function { name } => ("function", Some(name.to_string()), None, None),
435    }
436}
437
438const fn test_status_name(status: TestStatus) -> &'static str {
439    match status {
440        TestStatus::Success => "success",
441        TestStatus::Failure => "failure",
442        TestStatus::Skipped => "skipped",
443    }
444}
445
446const fn test_kind_name(kind: &TestKind) -> &'static str {
447    match kind {
448        TestKind::Unit { .. } => "unit",
449        TestKind::Fuzz { .. } => "fuzz",
450        TestKind::Invariant { .. } => "invariant",
451        TestKind::Table { .. } => "table",
452        TestKind::Symbolic { .. } => "symbolic",
453        TestKind::Replay { .. } => "replay",
454    }
455}
456
457/// A super verbose reporter for debugging coverage while it is still unstable.
458pub struct DebugReporter;
459
460impl CoverageReporter for DebugReporter {
461    fn name(&self) -> &'static str {
462        "debug"
463    }
464
465    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
466        for (path, items) in report.items_by_file() {
467            let src = fs::read_to_string(path)?;
468            sh_println!("{}:", path.display())?;
469            for item in items {
470                sh_println!("- {}", item.fmt_with_source(Some(&src)))?;
471            }
472            sh_println!()?;
473        }
474
475        for (contract_id, (cta, rta)) in &report.anchors {
476            if cta.anchors.is_empty() && rta.anchors.is_empty() {
477                continue;
478            }
479
480            let anchors = cta
481                .anchors
482                .iter()
483                .map(|anchor| (false, anchor))
484                .chain(rta.anchors.iter().map(|anchor| (true, anchor)))
485                .filter_map(|(is_runtime, anchor)| {
486                    let item = report
487                        .analyses
488                        .get(&contract_id.build_id)
489                        .and_then(|items| items.get(anchor.item_id))?;
490                    // Source filters retain analyses to keep anchor item IDs stable, so debug
491                    // output must apply the same reportable-source filter as other reporters.
492                    report
493                        .get_source_path(&contract_id.build_id, item.loc.source_id)
494                        .is_some()
495                        .then_some((is_runtime, anchor, item))
496                })
497                .collect::<Vec<_>>();
498            if anchors.is_empty() {
499                continue;
500            }
501
502            sh_println!("Anchors for {contract_id}:")?;
503            for (is_runtime, anchor, item) in anchors {
504                let kind = if is_runtime { " runtime" } else { "creation" };
505                sh_println!("- {kind} {anchor}: {item}")?;
506            }
507            sh_println!()?;
508        }
509
510        Ok(())
511    }
512}
513
514pub struct BytecodeReporter {
515    root: PathBuf,
516    destdir: PathBuf,
517}
518
519impl BytecodeReporter {
520    pub const fn new(root: PathBuf, destdir: PathBuf) -> Self {
521        Self { root, destdir }
522    }
523}
524
525impl CoverageReporter for BytecodeReporter {
526    fn name(&self) -> &'static str {
527        "bytecode"
528    }
529
530    fn needs_source_maps(&self) -> bool {
531        true
532    }
533
534    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
535        use std::fmt::Write;
536
537        fs::create_dir_all(&self.destdir)?;
538
539        let no_source_elements = Vec::new();
540        let mut line_number_cache = LineNumberCache::new(self.root.clone());
541
542        for (contract_id, hits) in &report.bytecode_hits {
543            let ops = disassemble_bytes(hits.bytecode().to_vec())?;
544            let mut formatted = String::new();
545
546            let source_elements =
547                report.source_maps.get(contract_id).map(|sm| &sm.1).unwrap_or(&no_source_elements);
548
549            for (code, source_element) in std::iter::zip(ops.iter(), source_elements) {
550                let hits = hits
551                    .get(code.offset)
552                    .map(|h| format!("[{h:03}]"))
553                    .unwrap_or("     ".to_owned());
554                let source_id = source_element.index();
555                let source_path = source_id
556                    .and_then(|i| report.get_source_path(&contract_id.build_id, i as usize));
557
558                let code = format!("{code:?}");
559                let start = source_element.offset() as usize;
560                let end = (source_element.offset() + source_element.length()) as usize;
561
562                if let Some(source_path) = source_path {
563                    let (sline, spos) = line_number_cache.get_position(source_path, start)?;
564                    let (eline, epos) = line_number_cache.get_position(source_path, end)?;
565                    writeln!(
566                        formatted,
567                        "{} {:40} // {}: {}:{}-{}:{} ({}-{})",
568                        hits,
569                        code,
570                        source_path.display(),
571                        sline,
572                        spos,
573                        eline,
574                        epos,
575                        start,
576                        end
577                    )?;
578                } else if let Some(source_id) = source_id {
579                    writeln!(formatted, "{hits} {code:40} // SRCID{source_id}: ({start}-{end})")?;
580                } else {
581                    writeln!(formatted, "{hits} {code:40}")?;
582                }
583            }
584            fs::write(
585                self.destdir.join(&*contract_id.contract_name).with_extension("asm"),
586                formatted,
587            )?;
588        }
589
590        Ok(())
591    }
592}
593
594/// Cache line number offsets for source files
595struct LineNumberCache {
596    root: PathBuf,
597    line_offsets: HashMap<PathBuf, Vec<usize>>,
598}
599
600impl LineNumberCache {
601    pub fn new(root: PathBuf) -> Self {
602        Self { root, line_offsets: HashMap::default() }
603    }
604
605    pub fn get_position(&mut self, path: &Path, offset: usize) -> eyre::Result<(usize, usize)> {
606        let line_offsets = match self.line_offsets.entry(path.to_path_buf()) {
607            hash_map::Entry::Occupied(o) => o.into_mut(),
608            hash_map::Entry::Vacant(v) => {
609                let text = fs::read_to_string(self.root.join(path))?;
610                let mut line_offsets = vec![0];
611                for line in text.lines() {
612                    let line_offset = line.as_ptr() as usize - text.as_ptr() as usize;
613                    line_offsets.push(line_offset);
614                }
615                v.insert(line_offsets)
616            }
617        };
618        let lo = match line_offsets.binary_search(&offset) {
619            Ok(lo) => lo,
620            Err(lo) => lo - 1,
621        };
622        let pos = offset - line_offsets.get(lo).unwrap() + 1;
623        Ok((lo, pos))
624    }
625}