Skip to main content

forge/
coverage.rs

1//! Coverage reports.
2
3use alloy_primitives::map::{HashMap, HashSet};
4use comfy_table::{
5    Attribute, Cell, Color, Row, Table, modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN,
6};
7use evm_disassembler::disassemble_bytes;
8use foundry_common::{fs, shell};
9use semver::Version;
10use std::{
11    collections::hash_map,
12    io::Write,
13    path::{Path, PathBuf},
14};
15
16pub use foundry_evm::coverage::*;
17
18/// A coverage reporter.
19pub trait CoverageReporter {
20    /// Returns a debug string for the reporter.
21    fn name(&self) -> &'static str;
22
23    /// Returns `true` if the reporter needs source maps for the final report.
24    fn needs_source_maps(&self) -> bool {
25        false
26    }
27
28    /// Runs the reporter.
29    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()>;
30}
31
32/// A simple summary reporter that prints the coverage results in a table.
33pub struct CoverageSummaryReporter {
34    /// The summary table.
35    table: Table,
36    /// The total coverage of the entire project.
37    total: CoverageSummary,
38}
39
40impl Default for CoverageSummaryReporter {
41    fn default() -> Self {
42        let mut table = Table::new();
43        if shell::is_markdown() {
44            table.load_preset(ASCII_MARKDOWN);
45        } else {
46            table.apply_modifier(UTF8_ROUND_CORNERS);
47        }
48
49        table.set_header(vec![
50            Cell::new("File"),
51            Cell::new("% Lines"),
52            Cell::new("% Statements"),
53            Cell::new("% Branches"),
54            Cell::new("% Funcs"),
55        ]);
56
57        Self { table, total: CoverageSummary::default() }
58    }
59}
60
61impl CoverageSummaryReporter {
62    fn add_row(&mut self, name: impl Into<Cell>, summary: CoverageSummary) {
63        let mut row = Row::new();
64        row.add_cell(name.into())
65            .add_cell(format_cell(summary.line_hits, summary.line_count))
66            .add_cell(format_cell(summary.statement_hits, summary.statement_count))
67            .add_cell(format_cell(summary.branch_hits, summary.branch_count))
68            .add_cell(format_cell(summary.function_hits, summary.function_count));
69        self.table.add_row(row);
70    }
71}
72
73impl CoverageReporter for CoverageSummaryReporter {
74    fn name(&self) -> &'static str {
75        "summary"
76    }
77
78    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
79        for (path, summary) in report.summary_by_file() {
80            self.total.merge(&summary);
81            self.add_row(path.display(), summary);
82        }
83
84        self.add_row("Total", self.total.clone());
85        sh_println!("\n{}", self.table)?;
86        Ok(())
87    }
88}
89
90fn format_cell(hits: usize, total: usize) -> Cell {
91    let percentage = if total == 0 { 1. } else { hits as f64 / total as f64 };
92
93    let mut cell =
94        Cell::new(format!("{:.2}% ({hits}/{total})", percentage * 100.)).fg(match percentage {
95            _ if total == 0 => Color::Grey,
96            _ if percentage < 0.5 => Color::Red,
97            _ if percentage < 0.75 => Color::Yellow,
98            _ => Color::Green,
99        });
100
101    if total == 0 {
102        cell = cell.add_attribute(Attribute::Dim);
103    }
104    cell
105}
106
107/// Writes the coverage report in [LCOV]'s [tracefile format].
108///
109/// [LCOV]: https://github.com/linux-test-project/lcov
110/// [tracefile format]: https://man.archlinux.org/man/geninfo.1.en#TRACEFILE_FORMAT
111pub struct LcovReporter {
112    path: PathBuf,
113    version: Version,
114}
115
116impl LcovReporter {
117    /// Create a new LCOV reporter.
118    pub fn new(path: PathBuf, version: Version) -> Self {
119        Self { path, version }
120    }
121}
122
123impl CoverageReporter for LcovReporter {
124    fn name(&self) -> &'static str {
125        "lcov"
126    }
127
128    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
129        let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?);
130
131        let mut fn_index = 0usize;
132        for (path, items) in report.items_by_file() {
133            let summary = CoverageSummary::from_items(items.iter().copied());
134
135            writeln!(out, "TN:")?;
136            writeln!(out, "SF:{}", path.display())?;
137
138            // First pass: collect line hits for DA records.
139            // Track both which lines have been recorded and the max hits per line.
140            let mut line_hits: HashMap<u32, u32> = HashMap::default();
141            for item in &items {
142                if matches!(item.kind, CoverageItemKind::Line | CoverageItemKind::Statement) {
143                    let line = item.loc.lines.start;
144                    line_hits
145                        .entry(line)
146                        .and_modify(|h| *h = (*h).max(item.hits))
147                        .or_insert(item.hits);
148                }
149            }
150
151            let mut recorded_lines = HashSet::new();
152
153            for item in items {
154                let line = item.loc.lines.start;
155                // `lines` is half-open, so we need to subtract 1 to get the last included line.
156                let end_line = item.loc.lines.end - 1;
157                let hits = item.hits;
158                match item.kind {
159                    CoverageItemKind::Function { ref name } => {
160                        let name = format!("{}.{name}", item.loc.contract_name);
161                        if self.version >= Version::new(2, 2, 0) {
162                            // v2.2 changed the FN format.
163                            writeln!(out, "FNL:{fn_index},{line},{end_line}")?;
164                            writeln!(out, "FNA:{fn_index},{hits},{name}")?;
165                            fn_index += 1;
166                        } else if self.version >= Version::new(2, 0, 0) {
167                            // v2.0 added end_line to FN.
168                            writeln!(out, "FN:{line},{end_line},{name}")?;
169                            writeln!(out, "FNDA:{hits},{name}")?;
170                        } else {
171                            writeln!(out, "FN:{line},{name}")?;
172                            writeln!(out, "FNDA:{hits},{name}")?;
173                        }
174                    }
175                    // Add lines / statement hits only once.
176                    CoverageItemKind::Line | CoverageItemKind::Statement
177                        if recorded_lines.insert(line) =>
178                    {
179                        writeln!(out, "DA:{line},{hits}")?;
180                    }
181                    CoverageItemKind::Branch { branch_id, path_id, .. } => {
182                        // Per LCOV spec: "-" means the expression was never evaluated (line not
183                        // executed), "0" means branch exists but was never taken.
184                        // Check if the line containing this branch was hit.
185                        let line_was_hit = line_hits.get(&line).is_some_and(|&h| h > 0);
186                        let hits_str = if hits > 0 {
187                            hits.to_string()
188                        } else if line_was_hit {
189                            "0".to_string()
190                        } else {
191                            "-".to_string()
192                        };
193                        writeln!(out, "BRDA:{line},{branch_id},{path_id},{hits_str}")?;
194                    }
195                    _ => {}
196                }
197            }
198
199            // Function summary
200            writeln!(out, "FNF:{}", summary.function_count)?;
201            writeln!(out, "FNH:{}", summary.function_hits)?;
202
203            // Line summary
204            writeln!(out, "LF:{}", summary.line_count)?;
205            writeln!(out, "LH:{}", summary.line_hits)?;
206
207            // Branch summary
208            writeln!(out, "BRF:{}", summary.branch_count)?;
209            writeln!(out, "BRH:{}", summary.branch_hits)?;
210
211            writeln!(out, "end_of_record")?;
212        }
213
214        out.flush()?;
215        sh_println!("Wrote LCOV report.")?;
216
217        Ok(())
218    }
219}
220
221/// A super verbose reporter for debugging coverage while it is still unstable.
222pub struct DebugReporter;
223
224impl CoverageReporter for DebugReporter {
225    fn name(&self) -> &'static str {
226        "debug"
227    }
228
229    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
230        for (path, items) in report.items_by_file() {
231            let src = fs::read_to_string(path)?;
232            sh_println!("{}:", path.display())?;
233            for item in items {
234                sh_println!("- {}", item.fmt_with_source(Some(&src)))?;
235            }
236            sh_println!()?;
237        }
238
239        for (contract_id, (cta, rta)) in &report.anchors {
240            if cta.is_empty() && rta.is_empty() {
241                continue;
242            }
243
244            sh_println!("Anchors for {contract_id}:")?;
245            let anchors = cta
246                .iter()
247                .map(|anchor| (false, anchor))
248                .chain(rta.iter().map(|anchor| (true, anchor)));
249            for (is_runtime, anchor) in anchors {
250                let kind = if is_runtime { " runtime" } else { "creation" };
251                sh_println!(
252                    "- {kind} {anchor}: {}",
253                    report
254                        .analyses
255                        .get(&contract_id.version)
256                        .and_then(|items| items.get(anchor.item_id))
257                        .map_or_else(|| "None".to_owned(), |item| item.to_string())
258                )?;
259            }
260            sh_println!()?;
261        }
262
263        Ok(())
264    }
265}
266
267pub struct BytecodeReporter {
268    root: PathBuf,
269    destdir: PathBuf,
270}
271
272impl BytecodeReporter {
273    pub fn new(root: PathBuf, destdir: PathBuf) -> Self {
274        Self { root, destdir }
275    }
276}
277
278impl CoverageReporter for BytecodeReporter {
279    fn name(&self) -> &'static str {
280        "bytecode"
281    }
282
283    fn needs_source_maps(&self) -> bool {
284        true
285    }
286
287    fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> {
288        use std::fmt::Write;
289
290        fs::create_dir_all(&self.destdir)?;
291
292        let no_source_elements = Vec::new();
293        let mut line_number_cache = LineNumberCache::new(self.root.clone());
294
295        for (contract_id, hits) in &report.bytecode_hits {
296            let ops = disassemble_bytes(hits.bytecode().to_vec())?;
297            let mut formatted = String::new();
298
299            let source_elements =
300                report.source_maps.get(contract_id).map(|sm| &sm.1).unwrap_or(&no_source_elements);
301
302            for (code, source_element) in std::iter::zip(ops.iter(), source_elements) {
303                let hits = hits
304                    .get(code.offset)
305                    .map(|h| format!("[{h:03}]"))
306                    .unwrap_or("     ".to_owned());
307                let source_id = source_element.index();
308                let source_path = source_id.and_then(|i| {
309                    report.source_paths.get(&(contract_id.version.clone(), i as usize))
310                });
311
312                let code = format!("{code:?}");
313                let start = source_element.offset() as usize;
314                let end = (source_element.offset() + source_element.length()) as usize;
315
316                if let Some(source_path) = source_path {
317                    let (sline, spos) = line_number_cache.get_position(source_path, start)?;
318                    let (eline, epos) = line_number_cache.get_position(source_path, end)?;
319                    writeln!(
320                        formatted,
321                        "{} {:40} // {}: {}:{}-{}:{} ({}-{})",
322                        hits,
323                        code,
324                        source_path.display(),
325                        sline,
326                        spos,
327                        eline,
328                        epos,
329                        start,
330                        end
331                    )?;
332                } else if let Some(source_id) = source_id {
333                    writeln!(formatted, "{hits} {code:40} // SRCID{source_id}: ({start}-{end})")?;
334                } else {
335                    writeln!(formatted, "{hits} {code:40}")?;
336                }
337            }
338            fs::write(
339                self.destdir.join(&*contract_id.contract_name).with_extension("asm"),
340                formatted,
341            )?;
342        }
343
344        Ok(())
345    }
346}
347
348/// Cache line number offsets for source files
349struct LineNumberCache {
350    root: PathBuf,
351    line_offsets: HashMap<PathBuf, Vec<usize>>,
352}
353
354impl LineNumberCache {
355    pub fn new(root: PathBuf) -> Self {
356        Self { root, line_offsets: HashMap::default() }
357    }
358
359    pub fn get_position(&mut self, path: &Path, offset: usize) -> eyre::Result<(usize, usize)> {
360        let line_offsets = match self.line_offsets.entry(path.to_path_buf()) {
361            hash_map::Entry::Occupied(o) => o.into_mut(),
362            hash_map::Entry::Vacant(v) => {
363                let text = fs::read_to_string(self.root.join(path))?;
364                let mut line_offsets = vec![0];
365                for line in text.lines() {
366                    let line_offset = line.as_ptr() as usize - text.as_ptr() as usize;
367                    line_offsets.push(line_offset);
368                }
369                v.insert(line_offsets)
370            }
371        };
372        let lo = match line_offsets.binary_search(&offset) {
373            Ok(lo) => lo,
374            Err(lo) => lo - 1,
375        };
376        let pos = offset - line_offsets.get(lo).unwrap() + 1;
377        Ok((lo, pos))
378    }
379}