Skip to main content

forge/cmd/
coverage.rs

1use super::{install, test::TestArgs, watch::WatchArgs};
2use crate::coverage::{
3    BytecodeReporter, ContractId, CoverageReport, CoverageReporter, CoverageSummaryReporter,
4    DebugReporter, ItemAnchor, LcovReporter,
5    analysis::{SourceAnalysis, SourceFiles},
6    anchors::find_anchors,
7};
8use alloy_primitives::{Address, Bytes, U256, map::HashMap};
9use clap::{Parser, ValueEnum, ValueHint};
10use eyre::Result;
11use foundry_cli::utils::{LoadConfig, STATIC_FUZZ_SEED};
12use foundry_common::{compile::ProjectCompiler, errors::convert_solar_errors};
13use foundry_compilers::{
14    Artifact, ArtifactId, Project, ProjectCompileOutput, ProjectPathsConfig, VYPER_EXTENSIONS,
15    artifacts::{CompactBytecode, CompactDeployedBytecode, sourcemap::SourceMap},
16};
17use foundry_config::Config;
18use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts};
19use rayon::prelude::*;
20use semver::{Version, VersionReq};
21use std::path::{Path, PathBuf};
22
23// Loads project's figment and merges the build cli arguments into it
24foundry_config::impl_figment_convert!(CoverageArgs, test);
25
26/// CLI arguments for `forge coverage`.
27#[derive(Parser)]
28pub struct CoverageArgs {
29    /// The report type to use for coverage.
30    ///
31    /// This flag can be used multiple times.
32    #[arg(long, value_enum, default_value = "summary")]
33    report: Vec<CoverageReportKind>,
34
35    /// The version of the LCOV "tracefile" format to use.
36    ///
37    /// Format: `MAJOR[.MINOR]`.
38    ///
39    /// Main differences:
40    /// - `1.x`: The original v1 format.
41    /// - `2.0`: Adds support for "line end" numbers for functions.
42    /// - `2.2`: Changes the format of functions.
43    #[arg(long, default_value = "1", value_parser = parse_lcov_version)]
44    lcov_version: Version,
45
46    /// Enable viaIR with minimum optimization
47    ///
48    /// This can fix most of the "stack too deep" errors while resulting a
49    /// relatively accurate source map.
50    #[arg(long)]
51    ir_minimum: bool,
52
53    /// The path to output the report.
54    ///
55    /// If not specified, the report will be stored in the root of the project.
56    #[arg(
57        long,
58        value_hint = ValueHint::FilePath,
59        value_name = "PATH"
60    )]
61    report_file: Option<PathBuf>,
62
63    /// Whether to include libraries in the coverage report.
64    #[arg(long)]
65    include_libs: bool,
66
67    /// Whether to exclude tests from the coverage report.
68    #[arg(long)]
69    exclude_tests: bool,
70
71    /// The coverage reporters to use. Constructed from the other fields.
72    #[arg(skip)]
73    reporters: Vec<Box<dyn CoverageReporter>>,
74
75    #[command(flatten)]
76    test: TestArgs,
77}
78
79impl CoverageArgs {
80    pub async fn run(mut self) -> Result<()> {
81        let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
82
83        // install missing dependencies
84        if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
85        {
86            // need to re-configure here to also catch additional remappings
87            config = self.load_config()?;
88        }
89
90        // Default to a static fuzz seed so coverage reports are deterministic,
91        // but allow the user to override it via `--fuzz-seed` or `[fuzz] seed` in config.
92        if config.fuzz.seed.is_none() {
93            config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
94        }
95
96        let (paths, mut output) = {
97            let (project, output) = self.build(&config)?;
98            (project.paths, output)
99        };
100
101        self.populate_reporters(&paths.root);
102
103        sh_println!("Analysing contracts...")?;
104        let report = self.prepare(&paths, &mut output)?;
105
106        sh_println!("Running tests...")?;
107        self.collect(&paths.root, &output, report, config, evm_opts).await
108    }
109
110    fn populate_reporters(&mut self, root: &Path) {
111        self.reporters = self
112            .report
113            .iter()
114            .map(|report_kind| match report_kind {
115                CoverageReportKind::Summary => {
116                    Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>
117                }
118                CoverageReportKind::Lcov => {
119                    let path =
120                        root.join(self.report_file.as_deref().unwrap_or("lcov.info".as_ref()));
121                    Box::new(LcovReporter::new(path, self.lcov_version.clone()))
122                }
123                CoverageReportKind::Bytecode => Box::new(BytecodeReporter::new(
124                    root.to_path_buf(),
125                    root.join("bytecode-coverage"),
126                )),
127                CoverageReportKind::Debug => Box::new(DebugReporter),
128            })
129            .collect::<Vec<_>>();
130    }
131
132    /// Builds the project.
133    fn build(&self, config: &Config) -> Result<(Project, ProjectCompileOutput)> {
134        let mut project = config.ephemeral_project()?;
135
136        if self.ir_minimum {
137            sh_warn!(
138                "`--ir-minimum` enables `viaIR` with minimum optimization, \
139                 which can result in inaccurate source mappings.\n\
140                 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
141                 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
142                 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
143            )?;
144        } else {
145            sh_warn!(
146                "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
147                 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
148                 enables `viaIR` with minimum optimization resolving most of the errors.\n\
149                 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
150            )?;
151        }
152
153        config.disable_optimizations(&mut project, self.ir_minimum);
154
155        let output = ProjectCompiler::default()
156            .compile(&project)?
157            .with_stripped_file_prefixes(project.root());
158
159        Ok((project, output))
160    }
161
162    /// Builds the coverage report.
163    #[instrument(name = "Coverage::prepare", skip_all)]
164    fn prepare(
165        &self,
166        project_paths: &ProjectPathsConfig,
167        output: &mut ProjectCompileOutput,
168    ) -> Result<CoverageReport> {
169        let mut report = CoverageReport::default();
170
171        output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
172            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
173                let _ = compiler.lower_asts();
174            }
175            convert_solar_errors(compiler.dcx())
176        })?;
177        let output = &*output;
178
179        // Collect source files.
180        let mut versioned_sources = HashMap::<Version, SourceFiles>::default();
181        for (path, source_file, version) in output.output().sources.sources_with_version() {
182            // Filter out vyper sources.
183            if path
184                .extension()
185                .and_then(|s| s.to_str())
186                .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
187            {
188                continue;
189            }
190
191            report.add_source(version.clone(), source_file.id as usize, path.clone());
192
193            // Filter out libs dependencies and tests.
194            if (!self.include_libs && project_paths.has_library_ancestor(path))
195                || (self.exclude_tests && project_paths.is_test(path))
196            {
197                continue;
198            }
199
200            let path = project_paths.root.join(path);
201            versioned_sources
202                .entry(version.clone())
203                .or_default()
204                .sources
205                .insert(source_file.id, path);
206        }
207
208        // Get source maps and bytecodes.
209        let artifacts: Vec<ArtifactData> = output
210            .artifact_ids()
211            .par_bridge() // This parses source maps, so we want to run it in parallel.
212            .filter_map(|(id, artifact)| {
213                let source_id = report.get_source_id(id.version.clone(), id.source.clone())?;
214                ArtifactData::new(&id, source_id, artifact)
215            })
216            .collect();
217
218        // Add coverage items.
219        for (version, sources) in &versioned_sources {
220            let source_analysis = SourceAnalysis::new(sources, output)?;
221            let anchors = artifacts
222                .par_iter()
223                .filter(|artifact| artifact.contract_id.version == *version)
224                .map(|artifact| {
225                    let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
226                    let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
227                    (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
228                })
229                .collect_vec_list();
230            report.add_anchors(anchors.into_iter().flatten());
231            report.add_analysis(version.clone(), source_analysis);
232        }
233
234        if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
235            report.add_source_maps(artifacts.into_iter().map(|artifact| {
236                (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
237            }));
238        }
239
240        Ok(report)
241    }
242
243    /// Runs tests, collects coverage data and generates the final report.
244    #[instrument(name = "Coverage::collect", skip_all)]
245    async fn collect(
246        mut self,
247        project_root: &Path,
248        output: &ProjectCompileOutput,
249        mut report: CoverageReport,
250        config: Config,
251        evm_opts: EvmOpts,
252    ) -> Result<()> {
253        let filter = self.test.filter(&config)?;
254        let outcome =
255            self.test.run_tests(project_root, config, evm_opts, output, &filter, true).await?;
256
257        let known_contracts = outcome.known_contracts.as_ref().unwrap().clone();
258
259        // Add hit data to the coverage report
260        let data = outcome.results.values().flat_map(|suite| {
261            let mut hits = Vec::new();
262            for result in suite.test_results.values() {
263                let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
264                for map in hit_maps.0.values() {
265                    if let Some((id, _)) = known_contracts.find_by_deployed_code(map.bytecode()) {
266                        hits.push((id, map, true));
267                    } else if let Some((id, _)) =
268                        known_contracts.find_by_creation_code(map.bytecode())
269                    {
270                        hits.push((id, map, false));
271                    }
272                }
273            }
274            hits
275        });
276
277        for (artifact_id, map, is_deployed_code) in data {
278            if let Some(source_id) =
279                report.get_source_id(artifact_id.version.clone(), artifact_id.source.clone())
280            {
281                report.add_hit_map(
282                    &ContractId {
283                        version: artifact_id.version.clone(),
284                        source_id,
285                        contract_name: artifact_id.name.as_str().into(),
286                    },
287                    map,
288                    is_deployed_code,
289                )?;
290            }
291        }
292
293        // Filter out ignored sources from the report.
294        if let Some(not_re) = &filter.args().coverage_pattern_inverse {
295            let file_root = filter.paths().root.as_path();
296            report.retain_sources(|path: &Path| {
297                let path = path.strip_prefix(file_root).unwrap_or(path);
298                !not_re.is_match(&path.to_string_lossy())
299            });
300        }
301
302        // Output final reports.
303        self.report(&report)?;
304
305        // Check for test failures after generating coverage report.
306        // This ensures coverage data is written even when tests fail.
307        outcome.ensure_ok(false)?;
308
309        Ok(())
310    }
311
312    #[instrument(name = "Coverage::report", skip_all)]
313    fn report(&mut self, report: &CoverageReport) -> Result<()> {
314        for reporter in &mut self.reporters {
315            let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
316            reporter.report(report)?;
317        }
318        Ok(())
319    }
320
321    pub const fn is_watch(&self) -> bool {
322        self.test.is_watch()
323    }
324
325    pub const fn watch(&self) -> &WatchArgs {
326        &self.test.watch
327    }
328}
329
330/// Coverage reports to generate.
331#[derive(Clone, Debug, Default, ValueEnum)]
332pub enum CoverageReportKind {
333    #[default]
334    Summary,
335    Lcov,
336    Debug,
337    Bytecode,
338}
339
340/// Helper function that will link references in unlinked bytecode to the 0 address.
341///
342/// This is needed in order to analyze the bytecode for contracts that use libraries.
343fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
344    let link_references = obj.link_references.clone();
345    for (file, libraries) in link_references {
346        for library in libraries.keys() {
347            obj.link(&file, library, Address::ZERO);
348        }
349    }
350
351    obj.object.resolve();
352    obj.object.into_bytes()
353}
354
355/// Helper function that will link references in unlinked bytecode to the 0 address.
356///
357/// This is needed in order to analyze the bytecode for contracts that use libraries.
358fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
359    obj.bytecode.and_then(dummy_link_bytecode)
360}
361
362pub struct ArtifactData {
363    pub contract_id: ContractId,
364    pub creation: BytecodeData,
365    pub deployed: BytecodeData,
366}
367
368impl ArtifactData {
369    pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
370        Some(Self {
371            contract_id: ContractId {
372                version: id.version.clone(),
373                source_id,
374                contract_name: id.name.as_str().into(),
375            },
376            creation: BytecodeData::new(
377                artifact.get_source_map()?.ok()?,
378                artifact
379                    .get_bytecode()
380                    .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
381            ),
382            deployed: BytecodeData::new(
383                artifact.get_source_map_deployed()?.ok()?,
384                artifact
385                    .get_deployed_bytecode()
386                    .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
387            ),
388        })
389    }
390}
391
392pub struct BytecodeData {
393    source_map: SourceMap,
394    bytecode: Bytes,
395    /// The instruction counter to program counter mapping.
396    ///
397    /// The source maps are indexed by *instruction counters*, which are the indexes of
398    /// instructions in the bytecode *minus any push bytes*.
399    ///
400    /// Since our line coverage inspector collects hit data using program counters, the anchors
401    /// also need to be based on program counters.
402    ic_pc_map: IcPcMap,
403}
404
405impl BytecodeData {
406    fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
407        let ic_pc_map = IcPcMap::new(&bytecode);
408        Self { source_map, bytecode, ic_pc_map }
409    }
410
411    pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> Vec<ItemAnchor> {
412        find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
413    }
414}
415
416fn parse_lcov_version(s: &str) -> Result<Version, String> {
417    let vr = VersionReq::parse(&format!("={s}")).map_err(|e| e.to_string())?;
418    let [c] = &vr.comparators[..] else {
419        return Err("invalid version".to_string());
420    };
421    if c.op != semver::Op::Exact {
422        return Err("invalid version".to_string());
423    }
424    if !c.pre.is_empty() {
425        return Err("pre-releases are not supported".to_string());
426    }
427    Ok(Version::new(c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0)))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn lcov_version() {
436        assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
437        assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
438        assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
439        assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
440        assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
441    }
442}