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