1use super::{
2 install,
3 test::{ProjectPathsAwareFilter, 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::{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 if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
149 {
150 config = self.load_config()?;
152 }
153
154 if config.fuzz.seed.is_none() {
157 config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
158 }
159
160 self.resolve_with(&config.coverage);
163 let filter = self.test.filter(&config)?;
164
165 let (paths, mut output) = {
166 let (project, output) = self.build(&config, &filter)?;
167 (project.paths, output)
168 };
169
170 if self.report_file.is_some() && self.file_report_count() > 1 {
171 sh_warn!(
172 "`--report-file` is ignored when multiple file reports are requested; \
173 each report will use its default output path"
174 )?;
175 }
176
177 self.populate_reporters(&paths.root);
178
179 sh_println!("Analysing contracts...")?;
180 let report = self.prepare(&paths, &mut output)?;
181
182 sh_println!("Running tests...")?;
183 self.collect(&paths.root, &output, report, config, evm_opts, filter).await
184 }
185
186 fn resolve_with(&mut self, config: &CoverageConfig) {
195 if self.report.is_empty() {
196 self.report.clone_from(&config.report);
197 }
198 self.lcov_version =
199 self.lcov_version_cli.clone().unwrap_or_else(|| config.lcov_version.clone());
200 if !self.ir_minimum {
201 self.ir_minimum = config.ir_minimum;
202 }
203 if self.report_file.is_none() {
204 self.report_file.clone_from(&config.report_file);
205 }
206 if !self.include_libs {
207 self.include_libs = config.include_libs;
208 }
209 if !self.exclude_tests {
210 self.exclude_tests = config.exclude_tests;
211 }
212 self.skip_files.clone_from(&config.skip_files);
215 }
216
217 fn populate_reporters(&mut self, root: &Path) {
218 self.reporters = self
219 .report
220 .iter()
221 .filter_map(|report_kind| match report_kind {
222 CoverageReportKind::Summary => {
223 Some(Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>)
224 }
225 CoverageReportKind::Lcov => {
226 let path = self.report_path(root, "lcov.info");
227 Some(Box::new(LcovReporter::new(path, self.lcov_version.clone())))
228 }
229 CoverageReportKind::Bytecode => Some(Box::new(BytecodeReporter::new(
230 root.to_path_buf(),
231 root.join("bytecode-coverage"),
232 ))),
233 CoverageReportKind::Debug => Some(Box::new(DebugReporter)),
234 CoverageReportKind::Attribution => None,
235 })
236 .collect::<Vec<_>>();
237 }
238
239 fn build(
241 &self,
242 config: &Config,
243 filter: &ProjectPathsAwareFilter,
244 ) -> Result<(Project, ProjectCompileOutput)> {
245 let mut project = config.ephemeral_project()?;
246
247 if self.ir_minimum {
248 sh_warn!(
249 "`--ir-minimum` enables `viaIR` with minimum optimization, \
250 which can result in inaccurate source mappings.\n\
251 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
252 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
253 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
254 )?;
255 } else {
256 sh_warn!(
257 "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
258 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
259 enables `viaIR` with minimum optimization resolving most of the errors.\n\
260 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
261 )?;
262 }
263
264 config.disable_optimizations(&mut project, self.ir_minimum);
265
266 let mut compiler = ProjectCompiler::new().dynamic_test_linking(config.dynamic_test_linking);
267 if filter.args().path_pattern.is_some() || filter.args().path_pattern_inverse.is_some() {
268 let sources = source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
269 .chain(
270 source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS)
271 .filter(|path| !path.is_sol_test() || filter.matches_path(path)),
274 )
275 .chain(source_files_iter(&config.script, MultiCompilerLanguage::FILE_EXTENSIONS))
277 .collect::<BTreeSet<_>>();
278 compiler = compiler.files(sources);
279 }
280 let output = compiler.compile(&project)?.with_stripped_file_prefixes(project.root());
281
282 Ok((project, output))
283 }
284
285 #[instrument(name = "Coverage::prepare", skip_all)]
287 fn prepare(
288 &self,
289 project_paths: &ProjectPathsConfig,
290 output: &mut ProjectCompileOutput,
291 ) -> Result<CoverageReport> {
292 let mut report = CoverageReport::default();
293
294 output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
295 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
296 let _ = compiler.lower_asts();
297 }
298 convert_solar_errors(compiler.dcx())
299 })?;
300 let output = &*output;
301
302 let mut sources_by_build = HashMap::<String, SourceFiles>::default();
304 for (path, sources) in &output.output().sources.0 {
305 if path
307 .extension()
308 .and_then(|s| s.to_str())
309 .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
310 {
311 continue;
312 }
313
314 for source in sources {
315 let source_file = &source.source_file;
316 report.add_source(source.build_id.clone(), source_file.id as usize, path.clone());
317
318 if (!self.include_libs && project_paths.has_library_ancestor(path))
320 || (self.exclude_tests && project_paths.is_test(path))
321 {
322 continue;
323 }
324
325 sources_by_build
326 .entry(source.build_id.clone())
327 .or_default()
328 .sources
329 .insert(source_file.id, project_paths.root.join(path));
330 }
331 }
332
333 let artifacts: Vec<ArtifactData> = output
335 .artifact_ids()
336 .par_bridge() .filter_map(|(id, artifact)| {
338 let source_id = report.get_source_id(&id.build_id, &id.source)?;
339 ArtifactData::new(&id, source_id, artifact)
340 })
341 .collect();
342
343 for (build_id, sources) in &sources_by_build {
345 let source_analysis = SourceAnalysis::new(sources, output)?;
346 let anchors = artifacts
347 .par_iter()
348 .filter(|artifact| artifact.contract_id.build_id == *build_id)
349 .map(|artifact| {
350 let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
351 let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
352 (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
353 })
354 .collect_vec_list();
355 report.add_anchors(anchors.into_iter().flatten());
356 report.add_analysis(build_id.clone(), source_analysis);
357 }
358
359 if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
360 report.add_source_maps(artifacts.into_iter().map(|artifact| {
361 (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
362 }));
363 }
364
365 Ok(report)
366 }
367
368 #[instrument(name = "Coverage::collect", skip_all)]
370 async fn collect(
371 mut self,
372 project_root: &Path,
373 output: &ProjectCompileOutput,
374 mut report: CoverageReport,
375 config: Config,
376 evm_opts: EvmOpts,
377 filter: ProjectPathsAwareFilter,
378 ) -> Result<()> {
379 let inline_config = Arc::new(InlineConfig::new_parsed(output, &config)?);
380 let outcome = self
381 .test
382 .run_tests(
383 project_root,
384 config,
385 evm_opts,
386 output,
387 &filter,
388 TestExecutionOptions::coverage(inline_config),
389 )
390 .await?;
391
392 let known_contracts = outcome.known_contracts.as_ref().unwrap();
393 let mut resolved_hit_maps = ResolvedHitMaps::default();
394
395 for suite in outcome.results.values() {
397 for result in suite.test_results.values() {
398 let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
399
400 for (code_hash, map) in &hit_maps.0 {
401 if let Some(resolved) = resolved_hit_maps.get(code_hash) {
402 report.add_hit_map(
403 &resolved.contract_id,
404 map,
405 resolved.is_deployed_code,
406 )?;
407 continue;
408 }
409
410 let Some((artifact_id, is_deployed_code)) = known_contracts
411 .find_by_deployed_code(map.bytecode())
412 .map(|(id, _)| (id, true))
413 .or_else(|| {
414 known_contracts
415 .find_by_creation_code(map.bytecode())
416 .map(|(id, _)| (id, false))
417 })
418 else {
419 continue;
420 };
421
422 let Some(source_id) =
423 report.get_source_id(&artifact_id.build_id, &artifact_id.source)
424 else {
425 continue;
426 };
427 let contract_id = ContractId {
428 version: artifact_id.version.clone(),
429 build_id: artifact_id.build_id.clone(),
430 source_id,
431 contract_name: artifact_id.name.as_str().into(),
432 };
433
434 report.add_hit_map(&contract_id, map, is_deployed_code)?;
435
436 resolved_hit_maps
437 .entry(*code_hash)
438 .or_insert(ResolvedHitMap { contract_id, is_deployed_code });
439 }
440 }
441 }
442
443 let file_root = filter.paths().root.as_path();
445 if let Some(not_re) = &filter.args().coverage_pattern_inverse {
446 report.retain_sources(|path: &Path| {
447 let path = path.strip_prefix(file_root).unwrap_or(path);
448 !not_re.is_match(&path.to_string_lossy())
449 });
450 }
451 if !self.skip_files.is_empty() {
452 let mut builder = GlobSetBuilder::new();
453 for pattern in &self.skip_files {
454 let glob = Glob::new(pattern).map_err(|e| {
455 eyre::eyre!("invalid glob in coverage.skip_files: '{pattern}': {e}")
456 })?;
457 builder.add(glob);
458 }
459 let set = builder
460 .build()
461 .map_err(|e| eyre::eyre!("failed to build coverage.skip_files glob set: {e}"))?;
462 report.retain_sources(|path: &Path| {
463 let path = path.strip_prefix(file_root).unwrap_or(path);
464 !set.is_match(path)
465 });
466 }
467
468 self.report(&report)?;
470
471 if self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution)) {
472 let reporter = CoverageAttributionReporter::new(
473 self.report_path(project_root, "coverage-attribution.json"),
474 );
475 reporter.report(&report, &outcome, &resolved_hit_maps)?;
476 }
477
478 outcome.ensure_ok(false)?;
481
482 Ok(())
483 }
484
485 #[instrument(name = "Coverage::report", skip_all)]
486 fn report(&mut self, report: &CoverageReport) -> Result<()> {
487 for reporter in &mut self.reporters {
488 let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
489 reporter.report(report)?;
490 }
491 Ok(())
492 }
493
494 pub const fn is_watch(&self) -> bool {
495 self.test.is_watch()
496 }
497
498 pub const fn watch(&self) -> &WatchArgs {
499 &self.test.watch
500 }
501}
502
503fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
507 let link_references = obj.link_references.clone();
508 for (file, libraries) in link_references {
509 for library in libraries.keys() {
510 obj.link(&file, library, Address::ZERO);
511 }
512 }
513
514 obj.object.resolve();
515 obj.object.into_bytes()
516}
517
518fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
522 obj.bytecode.and_then(dummy_link_bytecode)
523}
524
525pub struct ArtifactData {
526 pub contract_id: ContractId,
527 pub creation: BytecodeData,
528 pub deployed: BytecodeData,
529}
530
531impl ArtifactData {
532 pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
533 Some(Self {
534 contract_id: ContractId {
535 version: id.version.clone(),
536 build_id: id.build_id.clone(),
537 source_id,
538 contract_name: id.name.as_str().into(),
539 },
540 creation: BytecodeData::new(
541 artifact.get_source_map()?.ok()?,
542 artifact
543 .get_bytecode()
544 .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
545 ),
546 deployed: BytecodeData::new(
547 artifact.get_source_map_deployed()?.ok()?,
548 artifact
549 .get_deployed_bytecode()
550 .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
551 ),
552 })
553 }
554}
555
556pub struct BytecodeData {
557 source_map: SourceMap,
558 bytecode: Bytes,
559 ic_pc_map: IcPcMap,
567}
568
569impl BytecodeData {
570 fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
571 let ic_pc_map = IcPcMap::new(&bytecode);
572 Self { source_map, bytecode, ic_pc_map }
573 }
574
575 pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> Vec<ItemAnchor> {
576 find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn lcov_version() {
586 assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
587 assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
588 assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
589 assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
590 assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
591 }
592
593 #[test]
594 fn resolve_lcov_version_uses_config_when_cli_absent() {
595 let mut args = CoverageArgs::parse_from(["coverage"]);
596 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
597
598 args.resolve_with(&config);
599
600 assert_eq!(args.lcov_version, Version::new(2, 2, 0));
601 }
602
603 #[test]
604 fn resolve_lcov_version_keeps_explicit_cli_default() {
605 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "1"]);
606 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
607
608 args.resolve_with(&config);
609
610 assert_eq!(args.lcov_version, Version::new(1, 0, 0));
611 }
612
613 #[test]
614 fn resolve_lcov_version_keeps_explicit_cli_value() {
615 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "2"]);
616 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
617
618 args.resolve_with(&config);
619
620 assert_eq!(args.lcov_version, Version::new(2, 0, 0));
621 }
622}