forge/
coverage.rs

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