Skip to main content

forge/cmd/
coverage.rs

1use super::{
2    test::{ProjectPathsAwareFilter, TestArgs, TestExecutionOptions},
3    watch::WatchArgs,
4};
5use crate::coverage::{
6    BytecodeReporter, ContractId, CoverageAttributionReporter, CoverageReport, CoverageReporter,
7    CoverageSummaryReporter, DebugReporter, GapsReporter, ItemAnchors, LcovReporter,
8    ResolvedHitMap, ResolvedHitMaps,
9    analysis::{SourceAnalysis, SourceFiles},
10    anchors::{find_anchors, find_execution_anchors},
11};
12use alloy_json_abi::StateMutability;
13use alloy_primitives::{
14    Address, B256, Bytes, U256, keccak256,
15    map::{HashMap, HashSet},
16};
17use clap::{Parser, ValueHint};
18use eyre::Result;
19use foundry_cli::utils::{FoundryPathExt, LoadConfig, STATIC_FUZZ_SEED};
20use foundry_common::{
21    ContractsByArtifactBuilder, TestFilter, compile::ProjectCompiler, errors::convert_solar_errors,
22    version::SHORT_VERSION,
23};
24use foundry_compilers::{
25    Artifact, ArtifactId, Graph, Project, ProjectCompileOutput, ProjectPathsConfig,
26    VYPER_EXTENSIONS,
27    artifacts::{CompactBytecode, CompactDeployedBytecode, Source, sourcemap::SourceMap},
28    cache::SOLIDITY_FILES_CACHE_FILENAME,
29    compilers::{
30        Language,
31        multi::{MultiCompilerLanguage, MultiCompilerParser},
32    },
33    error::SolcError,
34    utils::source_files_iter,
35};
36use foundry_config::{
37    Config, CoverageConfig, CoverageReportKind, InlineConfig, parse_lcov_version,
38};
39use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts};
40use globset::{Glob, GlobSetBuilder};
41use rayon::prelude::*;
42use semver::Version;
43use std::{
44    collections::{BTreeMap, BTreeSet},
45    fs,
46    path::{Path, PathBuf},
47    sync::Arc,
48};
49
50// Loads project's figment and merges the build cli arguments into it
51foundry_config::impl_figment_convert!(CoverageArgs, test);
52
53/// CLI arguments for `forge coverage`.
54///
55/// Most flags here have a corresponding `[profile.<name>.coverage]` config
56/// option in `foundry.toml`. CLI flags take precedence over config; the helper
57/// `resolve_with` merges them after the config is loaded.
58#[derive(Parser)]
59#[command(after_long_help = r#"Source attribution:
60  Coverage follows compiler source maps. Inherited modifier code is reported under the
61  source where the modifier is declared. Dependency sources are excluded by default;
62  use `--include-libs` to include their coverage.
63
64Compatibility:
65  `forge coverage` supports test filters and `--watch`, but not test-only output or
66  execution modes such as `--json`, `--junit`, `--list`, `--debug`, flame profiles,
67  symbolic artifact replay, showmap replay, brutalization, or mutation testing. Use
68  `--report lcov` for interoperable coverage data, `--report attribution` for
69  Foundry's per-test JSON attribution report, or `--report gaps` for a JSON list of
70  uncovered source items."#)]
71pub struct CoverageArgs {
72    /// The report type to use for coverage.
73    ///
74    /// This flag can be used multiple times. Falls back to the
75    /// `[profile.<name>.coverage] report` config value when not provided
76    /// (default: `summary`).
77    #[arg(long, value_enum)]
78    report: Vec<CoverageReportKind>,
79
80    /// The version of the LCOV "tracefile" format to use.
81    ///
82    /// Format: `MAJOR[.MINOR]`.
83    ///
84    /// Main differences:
85    /// - `1.x`: The original v1 format.
86    /// - `2.0`: Adds support for "line end" numbers for functions.
87    /// - `2.2`: Changes the format of functions.
88    ///
89    /// Falls back to the `[profile.<name>.coverage] lcov_version` config value
90    /// when not provided.
91    #[arg(long = "lcov-version", value_parser = parse_lcov_version)]
92    lcov_version_cli: Option<Version>,
93
94    /// The resolved LCOV version to use after merging CLI and config values.
95    #[arg(skip = Version::new(1, 0, 0))]
96    lcov_version: Version,
97
98    /// Enable viaIR with minimum optimization
99    ///
100    /// This can fix most of the "stack too deep" errors while resulting a
101    /// relatively accurate source map.
102    #[arg(long)]
103    ir_minimum: bool,
104
105    /// The path to output the report.
106    ///
107    /// Used only when a single file report is requested. If not specified, the
108    /// report will be stored in the root of the project.
109    #[arg(
110        long,
111        value_hint = ValueHint::FilePath,
112        value_name = "PATH"
113    )]
114    report_file: Option<PathBuf>,
115
116    /// Include dependency sources in the coverage report.
117    #[arg(long)]
118    include_libs: bool,
119
120    /// Whether to exclude tests from the coverage report.
121    #[arg(long)]
122    exclude_tests: bool,
123
124    /// The coverage reporters to use. Constructed from the other fields.
125    #[arg(skip)]
126    reporters: Vec<Box<dyn CoverageReporter>>,
127
128    /// Glob patterns of source files to exclude from the coverage report.
129    /// Populated from `[profile.<name>.coverage] skip_files` after config is
130    /// loaded; not exposed directly on the CLI.
131    #[arg(skip)]
132    skip_files: Vec<String>,
133
134    #[command(flatten)]
135    test: TestArgs,
136}
137
138impl CoverageArgs {
139    fn report_path(&self, root: &Path, default_file_name: &str) -> PathBuf {
140        let report_file =
141            (self.file_report_count() == 1).then_some(self.report_file.as_deref()).flatten();
142        root.join(report_file.unwrap_or_else(|| Path::new(default_file_name)))
143    }
144
145    fn file_report_count(&self) -> usize {
146        let has_lcov = self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Lcov));
147        let has_attribution =
148            self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution));
149        let has_gaps = self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Gaps));
150        usize::from(has_lcov) + usize::from(has_attribution) + usize::from(has_gaps)
151    }
152
153    pub(crate) fn ensure_mode_compatible(&self) -> Result<()> {
154        self.test.ensure_coverage_mode_compatible()
155    }
156
157    pub async fn run(mut self) -> Result<()> {
158        self.ensure_mode_compatible()?;
159
160        let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
161
162        // install missing dependencies
163        self.install_missing_dependencies(&mut config)?;
164
165        // Default to a static fuzz seed so coverage reports are deterministic,
166        // but allow the user to override it via `--fuzz-seed` or `[fuzz] seed` in config.
167        if config.fuzz.seed.is_none() {
168            config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
169        }
170
171        // Merge CLI args with `[profile.<name>.coverage]` config values. CLI
172        // flags take precedence; unset CLI flags fall back to the config.
173        self.resolve_with(&config.coverage);
174        let filter = self.test.filter(&config)?;
175
176        let (paths, mut output) = {
177            let (project, output) = self.build(&config, &filter)?;
178            (project.paths, output)
179        };
180
181        if self.report_file.is_some() && self.file_report_count() > 1 {
182            sh_warn!(
183                "`--report-file` is ignored when multiple file reports are requested; \
184                 each report will use its default output path"
185            )?;
186        }
187
188        self.populate_reporters(&paths.root);
189
190        sh_println!("Analysing contracts...")?;
191        let report = self.prepare(&paths, &mut output)?;
192
193        sh_println!("Running tests...")?;
194        self.collect(&paths.root, &output, report, config, evm_opts, filter).await
195    }
196
197    /// Merge `[profile.<name>.coverage]` config values into this struct. CLI
198    /// flags already set on `self` win; unset/false flags inherit from
199    /// `config`.
200    ///
201    /// After this returns:
202    /// - `self.report` is non-empty.
203    /// - boolean flags reflect `cli || config` (CLI cannot disable a flag set to `true` in config;
204    ///   this matches the pre-existing flag-only semantics where booleans defaulted to `false`).
205    fn resolve_with(&mut self, config: &CoverageConfig) {
206        if self.report.is_empty() {
207            self.report.clone_from(&config.report);
208        }
209        self.lcov_version =
210            self.lcov_version_cli.clone().unwrap_or_else(|| config.lcov_version.clone());
211        if !self.ir_minimum {
212            self.ir_minimum = config.ir_minimum;
213        }
214        if self.report_file.is_none() {
215            self.report_file.clone_from(&config.report_file);
216        }
217        if !self.include_libs {
218            self.include_libs = config.include_libs;
219        }
220        if !self.exclude_tests {
221            self.exclude_tests = config.exclude_tests;
222        }
223        // Glob filters are additive — there's no CLI flag for these, so always
224        // take from config.
225        self.skip_files.clone_from(&config.skip_files);
226    }
227
228    fn populate_reporters(&mut self, root: &Path) {
229        self.reporters = self
230            .report
231            .iter()
232            .filter_map(|report_kind| match report_kind {
233                CoverageReportKind::Summary => {
234                    Some(Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>)
235                }
236                CoverageReportKind::Lcov => {
237                    let path = self.report_path(root, "lcov.info");
238                    Some(Box::new(LcovReporter::new(path, self.lcov_version.clone())))
239                }
240                CoverageReportKind::Bytecode => Some(Box::new(BytecodeReporter::new(
241                    root.to_path_buf(),
242                    root.join("bytecode-coverage"),
243                ))),
244                CoverageReportKind::Debug => Some(Box::new(DebugReporter)),
245                CoverageReportKind::Attribution => None,
246                CoverageReportKind::Gaps => {
247                    let path = self.report_path(root, "coverage-gaps.json");
248                    Some(Box::new(GapsReporter::new(root.to_path_buf(), path)))
249                }
250            })
251            .collect::<Vec<_>>();
252    }
253
254    /// Builds the project.
255    fn build(
256        &self,
257        config: &Config,
258        filter: &ProjectPathsAwareFilter,
259    ) -> Result<(Project, ProjectCompileOutput)> {
260        let mut project = config.ephemeral_project()?;
261        // A contended or unavailable cache retains ephemeral compilation. Keep the lock until
262        // artifacts and build contexts have been loaded or published together.
263        let cache = (config.cache && !config.deny.warnings())
264            .then(|| config.coverage_cache_path())
265            .flatten()
266            .and_then(|path| {
267                fs::create_dir_all(path.parent()?).ok()?;
268                let lock = Config::lock_coverage_cache(&path).ok()?;
269                match fs::create_dir(&path) {
270                    Ok(()) => {
271                        if fs::File::create_new(path.join(Config::COVERAGE_CACHE_MARKER)).is_err() {
272                            let _ = fs::remove_dir(&path);
273                            return None;
274                        }
275                    }
276                    Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
277                        if !path.join(Config::COVERAGE_CACHE_MARKER).is_file() {
278                            return None;
279                        }
280                    }
281                    Err(_) => return None,
282                }
283                Some((path, lock))
284            });
285        let original_paths = project.paths.clone();
286        if let Some((path, _)) = &cache {
287            project.cached = true;
288            project.no_artifacts = false;
289            project.paths.cache = path.join(SOLIDITY_FILES_CACHE_FILENAME);
290            project.paths.artifacts = path.join("artifacts");
291            project.paths.build_infos = path.join("build-info");
292            project.paths.slash_paths();
293        }
294
295        if self.ir_minimum {
296            sh_warn!(
297                "`--ir-minimum` enables `viaIR` with minimum optimization, \
298                 which can result in inaccurate source mappings.\n\
299                 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
300                 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
301                 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
302            )?;
303        } else {
304            sh_warn!(
305                "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
306                 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
307                 enables `viaIR` with minimum optimization resolving most of the errors.\n\
308                 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
309            )?;
310        }
311
312        config.disable_optimizations(&mut project, self.ir_minimum);
313
314        let files = (filter.args().path_pattern.is_some()
315            || filter.args().path_pattern_inverse.is_some())
316        .then(|| {
317            source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
318                .chain(
319                    source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS)
320                        // Preserve path-filter behavior for conventional test files while still
321                        // scanning non-test fixtures under the test root.
322                        .filter(|path| !path.is_sol_test() || filter.matches_path(path)),
323                )
324                // Coverage reports include scripts even though they are not test targets.
325                .chain(source_files_iter(&config.script, MultiCompilerLanguage::FILE_EXTENSIONS))
326                .collect::<BTreeSet<_>>()
327        });
328        let compile = |project: &Project| {
329            let mut compiler = ProjectCompiler::new()
330                .allow_linked_libraries(config.allow_linked_libraries)
331                .external_compilers(config)
332                .external_artifacts(false)
333                .dynamic_test_linking(config.dynamic_test_linking);
334            if let Some(files) = &files {
335                compiler = compiler.files(files.iter().cloned());
336            }
337            compiler.compile(project)
338        };
339        // Coverage needs complete build contexts, including sources without contract artifacts.
340        // Invalidate the owned cache as a whole on edits so source IDs, artifact names, and
341        // build-info files all belong to the same compilation.
342        let cached_output = (|| -> Result<Option<ProjectCompileOutput>> {
343            let Some((path, _)) = &cache else { return Ok(None) };
344            let mut sources = if let Some(files) = &files
345                && !files.is_empty()
346            {
347                Source::read_all(files)?
348            } else {
349                project.paths.read_input_files()?
350            };
351            if let Some(filter) = &project.sparse_output {
352                sources.retain(|path, _| filter.is_match(path));
353            }
354            let graph = Graph::<MultiCompilerParser>::resolve_sources(&project.paths, sources)?;
355            let resolved = graph.into_sources_by_version(&project)?;
356            let mut jobs = resolved
357                .sources
358                .iter()
359                .flat_map(|(language, jobs)| {
360                    jobs.iter().map(move |(version, sources, (profile, settings))| {
361                        let sources = sources
362                            .iter()
363                            .map(|(path, source)| {
364                                (path, source.content_hash(), source.kind.is_dirty())
365                            })
366                            .collect::<Vec<_>>();
367                        serde_json::to_value((language, version, profile, settings, sources))
368                    })
369                })
370                .collect::<Result<Vec<_>, _>>()?;
371            jobs.sort_unstable_by_key(serde_json::Value::to_string);
372            let primary_profiles = resolved.primary_profiles.iter().collect::<BTreeMap<_, _>>();
373            let mut identity = serde_json::to_value((
374                SHORT_VERSION,
375                &project.paths,
376                (&config.solc, &config.vyper, &config.extra_args),
377                (&config.extra_output, &config.extra_output_files),
378                config.dynamic_test_linking,
379                jobs,
380                primary_profiles,
381            ))?;
382            identity.sort_all_objects();
383            let fingerprint = keccak256(serde_json::to_vec(&identity)?).to_string();
384            let marker = path.join(Config::COVERAGE_CACHE_MARKER);
385            if fs::read_to_string(&marker).ok().as_deref() != Some(&fingerprint) {
386                // Retain ownership even if compilation fails before the new fingerprint is saved.
387                fs::write(&marker, "")?;
388                for entry in fs::read_dir(path)? {
389                    let entry = entry?;
390                    if entry.file_name() == Config::COVERAGE_CACHE_MARKER {
391                        continue;
392                    }
393                    if entry.file_type()?.is_dir() {
394                        fs::remove_dir_all(entry.path())?;
395                    } else {
396                        fs::remove_file(entry.path())?;
397                    }
398                }
399            }
400            let output = compile(&project)?;
401            // Compiler builds with no artifacts can be pruned from the cache. Coverage still
402            // reports their free functions, so recover their source IDs with a fresh compilation.
403            let mapped_sources = output
404                .builds()
405                .flat_map(|(_, build)| build.source_id_to_path.values().map(PathBuf::as_path))
406                .collect::<HashSet<_>>();
407            if output
408                .graph()
409                .files()
410                .any(|idx| !mapped_sources.contains(output.graph().node_path(idx)))
411            {
412                return Ok(None);
413            }
414            // Publication is optional once the compiler has returned a complete output.
415            if let Err(err) = fs::write(marker, fingerprint) {
416                debug!(%err, "failed to publish coverage cache fingerprint");
417            }
418            Ok(Some(output))
419        })();
420        let output = match cached_output {
421            Ok(Some(output)) => output,
422            Err(err)
423                if !err.chain().any(|cause| {
424                    cause.is::<std::io::Error>()
425                        || matches!(cause.downcast_ref(), Some(SolcError::Io(_)))
426                }) =>
427            {
428                return Err(err);
429            }
430            result => {
431                if let Err(err) = result {
432                    debug!(%err, "coverage cache unavailable; compiling without persistence");
433                }
434                project.cached = false;
435                project.no_artifacts = true;
436                project.paths = original_paths;
437                compile(&project)?
438            }
439        };
440        let output = output.with_stripped_file_prefixes(project.root());
441
442        Ok((project, output))
443    }
444
445    /// Builds the coverage report.
446    #[instrument(name = "Coverage::prepare", skip_all)]
447    fn prepare(
448        &self,
449        project_paths: &ProjectPathsConfig,
450        output: &mut ProjectCompileOutput,
451    ) -> Result<CoverageReport> {
452        let mut report = CoverageReport::default();
453
454        output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
455            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
456                let _ = compiler.lower_asts();
457            }
458            convert_solar_errors(compiler.dcx())
459        })?;
460        let output = &*output;
461
462        // Collect source files.
463        let mut sources_by_build = HashMap::<String, SourceFiles>::default();
464        for (build_id, build) in output.builds() {
465            for (source_id, path) in &build.source_id_to_path {
466                if output.graph().get_parsed_source(path).is_none()
467                    || path
468                        .extension()
469                        .and_then(|s| s.to_str())
470                        .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
471                {
472                    continue;
473                }
474                let path = path.strip_prefix(&project_paths.root).unwrap_or(path);
475                report.add_source(build_id.clone(), *source_id as usize, path.to_path_buf());
476
477                if (!self.include_libs && project_paths.has_library_ancestor(path))
478                    || (self.exclude_tests && project_paths.is_test(path))
479                {
480                    continue;
481                }
482
483                sources_by_build
484                    .entry(build_id.clone())
485                    .or_default()
486                    .sources
487                    .insert(*source_id, project_paths.root.join(path));
488            }
489        }
490
491        // Get source maps and bytecodes.
492        let artifacts: Vec<ArtifactData> = output
493            .artifact_ids()
494            .par_bridge() // This parses source maps, so we want to run it in parallel.
495            .filter_map(|(id, artifact)| {
496                let source_id = report.get_source_id(&id.build_id, &id.source)?;
497                ArtifactData::new(&id, source_id, artifact)
498            })
499            .collect();
500
501        // Add coverage items.
502        for (build_id, sources) in &sources_by_build {
503            let source_analysis = SourceAnalysis::new(sources, output)?;
504            let anchors = artifacts
505                .par_iter()
506                .filter(|artifact| artifact.contract_id.build_id == *build_id)
507                .map(|artifact| {
508                    let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
509                    let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
510                    (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
511                })
512                .collect_vec_list();
513            report.add_anchors(anchors.into_iter().flatten());
514            for artifact in
515                artifacts.iter().filter(|artifact| artifact.contract_id.build_id == *build_id)
516            {
517                let execution_anchors = find_execution_anchors(
518                    artifact.contract_id.source_id as u32,
519                    &artifact.contract_id.contract_name,
520                    &source_analysis,
521                );
522                report.add_execution_anchors(
523                    artifact.contract_id.clone(),
524                    execution_anchors,
525                    artifact.function_selectors.iter().copied(),
526                    artifact.has_receive,
527                    artifact.fallback_payable,
528                );
529            }
530            report.add_analysis(build_id.clone(), source_analysis);
531        }
532
533        if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
534            report.add_source_maps(artifacts.into_iter().map(|artifact| {
535                (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
536            }));
537        }
538
539        Ok(report)
540    }
541
542    /// Runs tests, collects coverage data and generates the final report.
543    #[instrument(name = "Coverage::collect", skip_all)]
544    async fn collect(
545        mut self,
546        project_root: &Path,
547        output: &ProjectCompileOutput,
548        mut report: CoverageReport,
549        config: Config,
550        evm_opts: EvmOpts,
551        filter: ProjectPathsAwareFilter,
552    ) -> Result<()> {
553        let inline_config = Arc::new(InlineConfig::new_parsed(output, &config)?);
554        let outcome = self
555            .test
556            .run_tests(
557                project_root,
558                config,
559                evm_opts,
560                output,
561                &filter,
562                TestExecutionOptions::coverage(inline_config),
563            )
564            .await?;
565
566        let known_contracts = outcome.known_contracts.as_ref().unwrap();
567        // Linking removes library references. Match runtime against the compiler artifacts to
568        // retain those offsets, while keeping linked bytecode for creation matching.
569        let runtime_contracts = ContractsByArtifactBuilder::new(
570            output.artifact_ids().map(|(id, artifact)| (id, artifact.into())),
571        )
572        .build();
573        let mut resolved_hit_maps = ResolvedHitMaps::default();
574        let mut unresolved_hit_maps = HashSet::<B256>::default();
575
576        // Add hit data to the coverage report
577        for suite in outcome.results.values() {
578            for result in suite.test_results.values() {
579                let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
580
581                for (code_hash, map) in &hit_maps.0 {
582                    if let Some(resolved) = resolved_hit_maps.get(code_hash) {
583                        report.add_hit_map(
584                            &resolved.contract_id,
585                            map,
586                            resolved.is_deployed_code,
587                        )?;
588                        continue;
589                    }
590                    if unresolved_hit_maps.contains(code_hash) {
591                        continue;
592                    }
593
594                    // Only exact matches are credited: source maps of a merely similar artifact
595                    // would attribute hits to code that never ran. Creation code may be followed
596                    // by any constructor data, which leaves the artifact's instructions unchanged.
597                    let Some((artifact_id, is_deployed_code)) = runtime_contracts
598                        .find_by_deployed_code_exact(map.bytecode())
599                        .map(|(id, _)| (id, true))
600                        .or_else(|| {
601                            known_contracts
602                                .iter()
603                                .find(|(_, contract)| {
604                                    contract
605                                        .bytecode()
606                                        .is_some_and(|code| map.bytecode().starts_with(code))
607                                })
608                                .map(|(id, _)| (id, false))
609                        })
610                    else {
611                        if let Some((similar, kind)) = known_contracts
612                            .find_by_deployed_code(map.bytecode())
613                            .map(|(id, _)| (id, "runtime"))
614                            .or_else(|| {
615                                known_contracts
616                                    .find_by_creation_code(map.bytecode())
617                                    .map(|(id, _)| (id, "creation"))
618                            })
619                        {
620                            sh_warn!(
621                                "excluding executed {kind} code from coverage: it resembles `{}` but does not match it exactly",
622                                similar.identifier()
623                            )?;
624                        }
625                        unresolved_hit_maps.insert(*code_hash);
626                        continue;
627                    };
628
629                    let Some(source_id) =
630                        report.get_source_id(&artifact_id.build_id, &artifact_id.source)
631                    else {
632                        continue;
633                    };
634                    let contract_id = ContractId {
635                        version: artifact_id.version.clone(),
636                        build_id: artifact_id.build_id.clone(),
637                        source_id,
638                        contract_name: artifact_id.name.as_str().into(),
639                    };
640
641                    report.add_hit_map(&contract_id, map, is_deployed_code)?;
642
643                    resolved_hit_maps
644                        .entry(*code_hash)
645                        .or_insert(ResolvedHitMap { contract_id, is_deployed_code });
646                }
647            }
648        }
649
650        // Filter out ignored sources from the report.
651        let file_root = filter.paths().root.as_path();
652        if let Some(not_re) = &filter.args().coverage_pattern_inverse {
653            report.retain_sources(|path: &Path| {
654                let path = path.strip_prefix(file_root).unwrap_or(path);
655                !not_re.is_match(&path.to_string_lossy())
656            });
657        }
658        if !self.skip_files.is_empty() {
659            let mut builder = GlobSetBuilder::new();
660            for pattern in &self.skip_files {
661                let glob = Glob::new(pattern).map_err(|e| {
662                    eyre::eyre!("invalid glob in coverage.skip_files: '{pattern}': {e}")
663                })?;
664                builder.add(glob);
665            }
666            let set = builder
667                .build()
668                .map_err(|e| eyre::eyre!("failed to build coverage.skip_files glob set: {e}"))?;
669            report.retain_sources(|path: &Path| {
670                let path = path.strip_prefix(file_root).unwrap_or(path);
671                !set.is_match(path)
672            });
673        }
674
675        // Output final reports.
676        self.report(&report)?;
677
678        if self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution)) {
679            let reporter = CoverageAttributionReporter::new(
680                self.report_path(project_root, "coverage-attribution.json"),
681            );
682            reporter.report(&report, &outcome, &resolved_hit_maps)?;
683        }
684
685        // Check for test failures after generating coverage report.
686        // This ensures coverage data is written even when tests fail.
687        outcome.ensure_ok(false)?;
688
689        Ok(())
690    }
691
692    #[instrument(name = "Coverage::report", skip_all)]
693    fn report(&mut self, report: &CoverageReport) -> Result<()> {
694        for reporter in &mut self.reporters {
695            let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
696            reporter.report(report)?;
697        }
698        Ok(())
699    }
700
701    pub const fn is_watch(&self) -> bool {
702        self.test.is_watch()
703    }
704
705    pub const fn watch(&self) -> &WatchArgs {
706        &self.test.watch
707    }
708}
709
710/// Helper function that will link references in unlinked bytecode to the 0 address.
711///
712/// This is needed in order to analyze the bytecode for contracts that use libraries.
713fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
714    let link_references = obj.link_references.clone();
715    for (file, libraries) in link_references {
716        for library in libraries.keys() {
717            obj.link(&file, library, Address::ZERO);
718        }
719    }
720
721    obj.object.resolve();
722    obj.object.into_bytes()
723}
724
725/// Helper function that will link references in unlinked bytecode to the 0 address.
726///
727/// This is needed in order to analyze the bytecode for contracts that use libraries.
728fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
729    obj.bytecode.and_then(dummy_link_bytecode)
730}
731
732pub struct ArtifactData {
733    pub contract_id: ContractId,
734    pub creation: BytecodeData,
735    pub deployed: BytecodeData,
736    pub function_selectors: Vec<[u8; 4]>,
737    pub has_receive: bool,
738    pub fallback_payable: bool,
739}
740
741impl ArtifactData {
742    pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
743        let abi = artifact.get_abi();
744        let function_selectors = abi
745            .as_ref()
746            .map(|abi| abi.functions().map(|function| function.selector().into()).collect())
747            .unwrap_or_default();
748        let has_receive = abi.as_ref().is_some_and(|abi| abi.receive.is_some());
749        let fallback_payable = abi
750            .as_ref()
751            .and_then(|abi| abi.fallback)
752            .is_some_and(|fallback| fallback.state_mutability == StateMutability::Payable);
753        Some(Self {
754            contract_id: ContractId {
755                version: id.version.clone(),
756                build_id: id.build_id.clone(),
757                source_id,
758                contract_name: id.name.as_str().into(),
759            },
760            creation: BytecodeData::new(
761                artifact.get_source_map()?.ok()?,
762                artifact
763                    .get_bytecode()
764                    .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
765            ),
766            deployed: BytecodeData::new(
767                artifact.get_source_map_deployed()?.ok()?,
768                artifact
769                    .get_deployed_bytecode()
770                    .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
771            ),
772            function_selectors,
773            has_receive,
774            fallback_payable,
775        })
776    }
777}
778
779pub struct BytecodeData {
780    source_map: SourceMap,
781    bytecode: Bytes,
782    /// The instruction counter to program counter mapping.
783    ///
784    /// The source maps are indexed by *instruction counters*, which are the indexes of
785    /// instructions in the bytecode *minus any push bytes*.
786    ///
787    /// Since our line coverage inspector collects hit data using program counters, the anchors
788    /// also need to be based on program counters.
789    ic_pc_map: IcPcMap,
790}
791
792impl BytecodeData {
793    fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
794        let ic_pc_map = IcPcMap::new(&bytecode);
795        Self { source_map, bytecode, ic_pc_map }
796    }
797
798    pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> ItemAnchors {
799        find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    #[test]
808    fn lcov_version() {
809        assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
810        assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
811        assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
812        assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
813        assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
814    }
815
816    #[test]
817    fn resolve_lcov_version_uses_config_when_cli_absent() {
818        let mut args = CoverageArgs::parse_from(["coverage"]);
819        let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
820
821        args.resolve_with(&config);
822
823        assert_eq!(args.lcov_version, Version::new(2, 2, 0));
824    }
825
826    #[test]
827    fn resolve_lcov_version_keeps_explicit_cli_default() {
828        let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "1"]);
829        let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
830
831        args.resolve_with(&config);
832
833        assert_eq!(args.lcov_version, Version::new(1, 0, 0));
834    }
835
836    #[test]
837    fn resolve_lcov_version_keeps_explicit_cli_value() {
838        let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "2"]);
839        let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
840
841        args.resolve_with(&config);
842
843        assert_eq!(args.lcov_version, Version::new(2, 0, 0));
844    }
845}