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
34foundry_config::impl_figment_convert!(CoverageArgs, test);
36
37#[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 #[arg(long, value_enum)]
56 report: Vec<CoverageReportKind>,
57
58 #[arg(long = "lcov-version", value_parser = parse_lcov_version)]
70 lcov_version_cli: Option<Version>,
71
72 #[arg(skip = Version::new(1, 0, 0))]
74 lcov_version: Version,
75
76 #[arg(long)]
81 ir_minimum: bool,
82
83 #[arg(
88 long,
89 value_hint = ValueHint::FilePath,
90 value_name = "PATH"
91 )]
92 report_file: Option<PathBuf>,
93
94 #[arg(long)]
96 include_libs: bool,
97
98 #[arg(long)]
100 exclude_tests: bool,
101
102 #[arg(skip)]
104 reporters: Vec<Box<dyn CoverageReporter>>,
105
106 #[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 if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
141 {
142 config = self.load_config()?;
144 }
145
146 if config.fuzz.seed.is_none() {
149 config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
150 }
151
152 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 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 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 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 #[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 let mut versioned_sources = HashMap::<Version, SourceFiles>::default();
280 for (path, source_file, version) in output.output().sources.sources_with_version() {
281 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 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 let artifacts: Vec<ArtifactData> = output
309 .artifact_ids()
310 .par_bridge() .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 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 #[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 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 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 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 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
476fn 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
491fn 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 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}