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::{Address, Bytes, U256, map::HashMap};
14use clap::{Parser, ValueHint};
15use eyre::Result;
16use foundry_cli::utils::{FoundryPathExt, LoadConfig, STATIC_FUZZ_SEED};
17use foundry_common::{TestFilter, compile::ProjectCompiler, errors::convert_solar_errors};
18use foundry_compilers::{
19 Artifact, ArtifactId, Project, ProjectCompileOutput, ProjectPathsConfig, VYPER_EXTENSIONS,
20 artifacts::{CompactBytecode, CompactDeployedBytecode, sourcemap::SourceMap},
21 compilers::{Language, multi::MultiCompilerLanguage},
22 utils::source_files_iter,
23};
24use foundry_config::{
25 Config, CoverageConfig, CoverageReportKind, InlineConfig, parse_lcov_version,
26};
27use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts};
28use globset::{Glob, GlobSetBuilder};
29use rayon::prelude::*;
30use semver::Version;
31use std::{
32 collections::BTreeSet,
33 path::{Path, PathBuf},
34 sync::Arc,
35};
36
37foundry_config::impl_figment_convert!(CoverageArgs, test);
39
40#[derive(Parser)]
46#[command(after_long_help = r#"Source attribution:
47 Coverage follows compiler source maps. Inherited modifier code is reported under the
48 source where the modifier is declared. Dependency sources are excluded by default;
49 use `--include-libs` to include their coverage.
50
51Compatibility:
52 `forge coverage` supports test filters and `--watch`, but not test-only output or
53 execution modes such as `--json`, `--junit`, `--list`, `--debug`, flame profiles,
54 symbolic artifact replay, showmap replay, brutalization, or mutation testing. Use
55 `--report lcov` for interoperable coverage data or `--report attribution` for
56 Foundry's per-test JSON attribution report."#)]
57pub struct CoverageArgs {
58 #[arg(long, value_enum)]
64 report: Vec<CoverageReportKind>,
65
66 #[arg(long = "lcov-version", value_parser = parse_lcov_version)]
78 lcov_version_cli: Option<Version>,
79
80 #[arg(skip = Version::new(1, 0, 0))]
82 lcov_version: Version,
83
84 #[arg(long)]
89 ir_minimum: bool,
90
91 #[arg(
96 long,
97 value_hint = ValueHint::FilePath,
98 value_name = "PATH"
99 )]
100 report_file: Option<PathBuf>,
101
102 #[arg(long)]
104 include_libs: bool,
105
106 #[arg(long)]
108 exclude_tests: bool,
109
110 #[arg(skip)]
112 reporters: Vec<Box<dyn CoverageReporter>>,
113
114 #[arg(skip)]
118 skip_files: Vec<String>,
119
120 #[command(flatten)]
121 test: TestArgs,
122}
123
124impl CoverageArgs {
125 fn report_path(&self, root: &Path, default_file_name: &str) -> PathBuf {
126 let report_file =
127 (self.file_report_count() == 1).then_some(self.report_file.as_deref()).flatten();
128 root.join(report_file.unwrap_or_else(|| Path::new(default_file_name)))
129 }
130
131 fn file_report_count(&self) -> usize {
132 let has_lcov = self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Lcov));
133 let has_attribution =
134 self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution));
135 usize::from(has_lcov) + usize::from(has_attribution)
136 }
137
138 pub(crate) fn ensure_mode_compatible(&self) -> Result<()> {
139 self.test.ensure_coverage_mode_compatible()
140 }
141
142 pub async fn run(mut self) -> Result<()> {
143 self.ensure_mode_compatible()?;
144
145 let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
146
147 self.install_missing_dependencies(&mut config)?;
149
150 if config.fuzz.seed.is_none() {
153 config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
154 }
155
156 self.resolve_with(&config.coverage);
159 let filter = self.test.filter(&config)?;
160
161 let (paths, mut output) = {
162 let (project, output) = self.build(&config, &filter)?;
163 (project.paths, output)
164 };
165
166 if self.report_file.is_some() && self.file_report_count() > 1 {
167 sh_warn!(
168 "`--report-file` is ignored when multiple file reports are requested; \
169 each report will use its default output path"
170 )?;
171 }
172
173 self.populate_reporters(&paths.root);
174
175 sh_println!("Analysing contracts...")?;
176 let report = self.prepare(&paths, &mut output)?;
177
178 sh_println!("Running tests...")?;
179 self.collect(&paths.root, &output, report, config, evm_opts, filter).await
180 }
181
182 fn resolve_with(&mut self, config: &CoverageConfig) {
191 if self.report.is_empty() {
192 self.report.clone_from(&config.report);
193 }
194 self.lcov_version =
195 self.lcov_version_cli.clone().unwrap_or_else(|| config.lcov_version.clone());
196 if !self.ir_minimum {
197 self.ir_minimum = config.ir_minimum;
198 }
199 if self.report_file.is_none() {
200 self.report_file.clone_from(&config.report_file);
201 }
202 if !self.include_libs {
203 self.include_libs = config.include_libs;
204 }
205 if !self.exclude_tests {
206 self.exclude_tests = config.exclude_tests;
207 }
208 self.skip_files.clone_from(&config.skip_files);
211 }
212
213 fn populate_reporters(&mut self, root: &Path) {
214 self.reporters = self
215 .report
216 .iter()
217 .filter_map(|report_kind| match report_kind {
218 CoverageReportKind::Summary => {
219 Some(Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>)
220 }
221 CoverageReportKind::Lcov => {
222 let path = self.report_path(root, "lcov.info");
223 Some(Box::new(LcovReporter::new(path, self.lcov_version.clone())))
224 }
225 CoverageReportKind::Bytecode => Some(Box::new(BytecodeReporter::new(
226 root.to_path_buf(),
227 root.join("bytecode-coverage"),
228 ))),
229 CoverageReportKind::Debug => Some(Box::new(DebugReporter)),
230 CoverageReportKind::Attribution => None,
231 })
232 .collect::<Vec<_>>();
233 }
234
235 fn build(
237 &self,
238 config: &Config,
239 filter: &ProjectPathsAwareFilter,
240 ) -> Result<(Project, ProjectCompileOutput)> {
241 let mut project = config.ephemeral_project()?;
242
243 if self.ir_minimum {
244 sh_warn!(
245 "`--ir-minimum` enables `viaIR` with minimum optimization, \
246 which can result in inaccurate source mappings.\n\
247 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
248 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
249 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
250 )?;
251 } else {
252 sh_warn!(
253 "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
254 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
255 enables `viaIR` with minimum optimization resolving most of the errors.\n\
256 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
257 )?;
258 }
259
260 config.disable_optimizations(&mut project, self.ir_minimum);
261
262 let mut compiler = ProjectCompiler::new().dynamic_test_linking(config.dynamic_test_linking);
263 if filter.args().path_pattern.is_some() || filter.args().path_pattern_inverse.is_some() {
264 let sources = source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
265 .chain(
266 source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS)
267 .filter(|path| !path.is_sol_test() || filter.matches_path(path)),
270 )
271 .chain(source_files_iter(&config.script, MultiCompilerLanguage::FILE_EXTENSIONS))
273 .collect::<BTreeSet<_>>();
274 compiler = compiler.files(sources);
275 }
276 let output = compiler.compile(&project)?.with_stripped_file_prefixes(project.root());
277
278 Ok((project, output))
279 }
280
281 #[instrument(name = "Coverage::prepare", skip_all)]
283 fn prepare(
284 &self,
285 project_paths: &ProjectPathsConfig,
286 output: &mut ProjectCompileOutput,
287 ) -> Result<CoverageReport> {
288 let mut report = CoverageReport::default();
289
290 output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
291 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
292 let _ = compiler.lower_asts();
293 }
294 convert_solar_errors(compiler.dcx())
295 })?;
296 let output = &*output;
297
298 let mut sources_by_build = HashMap::<String, SourceFiles>::default();
300 for (path, sources) in &output.output().sources.0 {
301 if path
303 .extension()
304 .and_then(|s| s.to_str())
305 .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
306 {
307 continue;
308 }
309
310 for source in sources {
311 let source_file = &source.source_file;
312 report.add_source(source.build_id.clone(), source_file.id as usize, path.clone());
313
314 if (!self.include_libs && project_paths.has_library_ancestor(path))
316 || (self.exclude_tests && project_paths.is_test(path))
317 {
318 continue;
319 }
320
321 sources_by_build
322 .entry(source.build_id.clone())
323 .or_default()
324 .sources
325 .insert(source_file.id, project_paths.root.join(path));
326 }
327 }
328
329 let artifacts: Vec<ArtifactData> = output
331 .artifact_ids()
332 .par_bridge() .filter_map(|(id, artifact)| {
334 let source_id = report.get_source_id(&id.build_id, &id.source)?;
335 ArtifactData::new(&id, source_id, artifact)
336 })
337 .collect();
338
339 for (build_id, sources) in &sources_by_build {
341 let source_analysis = SourceAnalysis::new(sources, output)?;
342 let anchors = artifacts
343 .par_iter()
344 .filter(|artifact| artifact.contract_id.build_id == *build_id)
345 .map(|artifact| {
346 let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
347 let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
348 (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
349 })
350 .collect_vec_list();
351 report.add_anchors(anchors.into_iter().flatten());
352 for artifact in
353 artifacts.iter().filter(|artifact| artifact.contract_id.build_id == *build_id)
354 {
355 let execution_anchors = find_execution_anchors(
356 artifact.contract_id.source_id as u32,
357 &artifact.contract_id.contract_name,
358 &source_analysis,
359 );
360 report.add_execution_anchors(
361 artifact.contract_id.clone(),
362 execution_anchors,
363 artifact.function_selectors.iter().copied(),
364 artifact.has_receive,
365 artifact.fallback_payable,
366 );
367 }
368 report.add_analysis(build_id.clone(), source_analysis);
369 }
370
371 if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
372 report.add_source_maps(artifacts.into_iter().map(|artifact| {
373 (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
374 }));
375 }
376
377 Ok(report)
378 }
379
380 #[instrument(name = "Coverage::collect", skip_all)]
382 async fn collect(
383 mut self,
384 project_root: &Path,
385 output: &ProjectCompileOutput,
386 mut report: CoverageReport,
387 config: Config,
388 evm_opts: EvmOpts,
389 filter: ProjectPathsAwareFilter,
390 ) -> Result<()> {
391 let inline_config = Arc::new(InlineConfig::new_parsed(output, &config)?);
392 let outcome = self
393 .test
394 .run_tests(
395 project_root,
396 config,
397 evm_opts,
398 output,
399 &filter,
400 TestExecutionOptions::coverage(inline_config),
401 )
402 .await?;
403
404 let known_contracts = outcome.known_contracts.as_ref().unwrap();
405 let mut resolved_hit_maps = ResolvedHitMaps::default();
406
407 for suite in outcome.results.values() {
409 for result in suite.test_results.values() {
410 let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
411
412 for (code_hash, map) in &hit_maps.0 {
413 if let Some(resolved) = resolved_hit_maps.get(code_hash) {
414 report.add_hit_map(
415 &resolved.contract_id,
416 map,
417 resolved.is_deployed_code,
418 )?;
419 continue;
420 }
421
422 let Some((artifact_id, is_deployed_code)) = known_contracts
423 .find_by_deployed_code(map.bytecode())
424 .map(|(id, _)| (id, true))
425 .or_else(|| {
426 known_contracts
427 .find_by_creation_code(map.bytecode())
428 .map(|(id, _)| (id, false))
429 })
430 else {
431 continue;
432 };
433
434 let Some(source_id) =
435 report.get_source_id(&artifact_id.build_id, &artifact_id.source)
436 else {
437 continue;
438 };
439 let contract_id = ContractId {
440 version: artifact_id.version.clone(),
441 build_id: artifact_id.build_id.clone(),
442 source_id,
443 contract_name: artifact_id.name.as_str().into(),
444 };
445
446 report.add_hit_map(&contract_id, map, is_deployed_code)?;
447
448 resolved_hit_maps
449 .entry(*code_hash)
450 .or_insert(ResolvedHitMap { contract_id, is_deployed_code });
451 }
452 }
453 }
454
455 let file_root = filter.paths().root.as_path();
457 if let Some(not_re) = &filter.args().coverage_pattern_inverse {
458 report.retain_sources(|path: &Path| {
459 let path = path.strip_prefix(file_root).unwrap_or(path);
460 !not_re.is_match(&path.to_string_lossy())
461 });
462 }
463 if !self.skip_files.is_empty() {
464 let mut builder = GlobSetBuilder::new();
465 for pattern in &self.skip_files {
466 let glob = Glob::new(pattern).map_err(|e| {
467 eyre::eyre!("invalid glob in coverage.skip_files: '{pattern}': {e}")
468 })?;
469 builder.add(glob);
470 }
471 let set = builder
472 .build()
473 .map_err(|e| eyre::eyre!("failed to build coverage.skip_files glob set: {e}"))?;
474 report.retain_sources(|path: &Path| {
475 let path = path.strip_prefix(file_root).unwrap_or(path);
476 !set.is_match(path)
477 });
478 }
479
480 self.report(&report)?;
482
483 if self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution)) {
484 let reporter = CoverageAttributionReporter::new(
485 self.report_path(project_root, "coverage-attribution.json"),
486 );
487 reporter.report(&report, &outcome, &resolved_hit_maps)?;
488 }
489
490 outcome.ensure_ok(false)?;
493
494 Ok(())
495 }
496
497 #[instrument(name = "Coverage::report", skip_all)]
498 fn report(&mut self, report: &CoverageReport) -> Result<()> {
499 for reporter in &mut self.reporters {
500 let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
501 reporter.report(report)?;
502 }
503 Ok(())
504 }
505
506 pub const fn is_watch(&self) -> bool {
507 self.test.is_watch()
508 }
509
510 pub const fn watch(&self) -> &WatchArgs {
511 &self.test.watch
512 }
513}
514
515fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
519 let link_references = obj.link_references.clone();
520 for (file, libraries) in link_references {
521 for library in libraries.keys() {
522 obj.link(&file, library, Address::ZERO);
523 }
524 }
525
526 obj.object.resolve();
527 obj.object.into_bytes()
528}
529
530fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
534 obj.bytecode.and_then(dummy_link_bytecode)
535}
536
537pub struct ArtifactData {
538 pub contract_id: ContractId,
539 pub creation: BytecodeData,
540 pub deployed: BytecodeData,
541 pub function_selectors: Vec<[u8; 4]>,
542 pub has_receive: bool,
543 pub fallback_payable: bool,
544}
545
546impl ArtifactData {
547 pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
548 let abi = artifact.get_abi();
549 let function_selectors = abi
550 .as_ref()
551 .map(|abi| abi.functions().map(|function| function.selector().into()).collect())
552 .unwrap_or_default();
553 let has_receive = abi.as_ref().is_some_and(|abi| abi.receive.is_some());
554 let fallback_payable = abi
555 .as_ref()
556 .and_then(|abi| abi.fallback)
557 .is_some_and(|fallback| fallback.state_mutability == StateMutability::Payable);
558 Some(Self {
559 contract_id: ContractId {
560 version: id.version.clone(),
561 build_id: id.build_id.clone(),
562 source_id,
563 contract_name: id.name.as_str().into(),
564 },
565 creation: BytecodeData::new(
566 artifact.get_source_map()?.ok()?,
567 artifact
568 .get_bytecode()
569 .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
570 ),
571 deployed: BytecodeData::new(
572 artifact.get_source_map_deployed()?.ok()?,
573 artifact
574 .get_deployed_bytecode()
575 .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
576 ),
577 function_selectors,
578 has_receive,
579 fallback_payable,
580 })
581 }
582}
583
584pub struct BytecodeData {
585 source_map: SourceMap,
586 bytecode: Bytes,
587 ic_pc_map: IcPcMap,
595}
596
597impl BytecodeData {
598 fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
599 let ic_pc_map = IcPcMap::new(&bytecode);
600 Self { source_map, bytecode, ic_pc_map }
601 }
602
603 pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> ItemAnchors {
604 find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn lcov_version() {
614 assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
615 assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
616 assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
617 assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
618 assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
619 }
620
621 #[test]
622 fn resolve_lcov_version_uses_config_when_cli_absent() {
623 let mut args = CoverageArgs::parse_from(["coverage"]);
624 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
625
626 args.resolve_with(&config);
627
628 assert_eq!(args.lcov_version, Version::new(2, 2, 0));
629 }
630
631 #[test]
632 fn resolve_lcov_version_keeps_explicit_cli_default() {
633 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "1"]);
634 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
635
636 args.resolve_with(&config);
637
638 assert_eq!(args.lcov_version, Version::new(1, 0, 0));
639 }
640
641 #[test]
642 fn resolve_lcov_version_keeps_explicit_cli_value() {
643 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "2"]);
644 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
645
646 args.resolve_with(&config);
647
648 assert_eq!(args.lcov_version, Version::new(2, 0, 0));
649 }
650}