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