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    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
120/// Writes the coverage report in [LCOV]'s [tracefile format].
121///
122/// [LCOV]: https://github.com/linux-test-project/lcov
123/// [tracefile format]: https://man.archlinux.org/man/geninfo.1.en#TRACEFILE_FORMAT
124pub struct LcovReporter {
125    path: PathBuf,
126    version: Version,
127}
128
129impl LcovReporter {
130    /// Create a new LCOV reporter.
131    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            // First pass: collect line hits for DA records.
152            // Track both which lines have been recorded and the max hits per line.
153            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                // `lines` is half-open, so we need to subtract 1 to get the last included line.
169                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                            // v2.2 changed the FN format.
176                            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                            // v2.0 added end_line to FN.
181                            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                    // Add lines / statement hits only once.
189                    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                        // Per LCOV spec: "-" means the expression was never evaluated (line not
196                        // executed), "0" means branch exists but was never taken.
197                        // Check if the line containing this branch was hit.
198                        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            // Function summary
213            writeln!(out, "FNF:{}", summary.function_count)?;
214            writeln!(out, "FNH:{}", summary.function_hits)?;
215
216            // Line summary
217            writeln!(out, "LF:{}", summary.line_count)?;
218            writeln!(out, "LH:{}", summary.line_hits)?;
219
220            // Branch summary
221            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
234/// Writes per-test coverage attribution as JSON.
235pub struct CoverageAttributionReporter {
236    path: PathBuf,
237}
238
239/// A hit map resolved to the contract coverage metadata it belongs to.
240pub 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    /// Create a new attribution reporter.
249    pub const fn new(path: PathBuf) -> Self {
250        Self { path }
251    }
252
253    /// Writes per-test coverage attribution for the provided outcome.
254    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/// Top-level JSON payload for per-test coverage attribution.
276#[derive(Serialize)]
277struct AttributionReport<'a> {
278    version: u8,
279    tests: AttributionTests<'a>,
280}
281
282/// Coverage attributed to a single executed test.
283#[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/// A source range covered by a test, with hit counts and item metadata.
293#[derive(Serialize)]
294struct AttributionItem {
295    source: String,
296    contract: String,
297    kind: &'static str,
298    /// The start of a 1-based, half-open line range.
299    line_start: u32,
300    /// The end of a 1-based, half-open line range.
301    line_end: u32,
302    /// The start of a 0-based, half-open byte range.
303    byte_start: u32,
304    /// The end of a 0-based, half-open byte range.
305    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
315/// Serializer state for streaming attribution entries from test results.
316struct 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
450/// A super verbose reporter for debugging coverage while it is still unstable.
451pub 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                    // Source filters retain analyses to keep anchor item IDs stable, so debug
483                    // output must apply the same reportable-source filter as other reporters.
484                    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
586/// Cache line number offsets for source files
587struct 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}