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