Skip to main content

forge/cmd/
coverage.rs

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