1use super::{install, test::TestArgs, watch::WatchArgs};
2use crate::coverage::{
3 BytecodeReporter, ContractId, CoverageReport, CoverageReporter, CoverageSummaryReporter,
4 DebugReporter, ItemAnchor, LcovReporter,
5 analysis::{SourceAnalysis, SourceFiles},
6 anchors::find_anchors,
7};
8use alloy_primitives::{Address, Bytes, U256, map::HashMap};
9use clap::{Parser, ValueHint};
10use eyre::Result;
11use foundry_cli::utils::{LoadConfig, STATIC_FUZZ_SEED};
12use foundry_common::{compile::ProjectCompiler, errors::convert_solar_errors};
13use foundry_compilers::{
14 Artifact, ArtifactId, Project, ProjectCompileOutput, ProjectPathsConfig, VYPER_EXTENSIONS,
15 artifacts::{CompactBytecode, CompactDeployedBytecode, sourcemap::SourceMap},
16};
17use foundry_config::{Config, CoverageConfig, CoverageReportKind, parse_lcov_version};
18use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts};
19use globset::{Glob, GlobSetBuilder};
20use rayon::prelude::*;
21use semver::Version;
22use std::path::{Path, PathBuf};
23
24foundry_config::impl_figment_convert!(CoverageArgs, test);
26
27#[derive(Parser)]
33pub struct CoverageArgs {
34 #[arg(long, value_enum)]
40 report: Vec<CoverageReportKind>,
41
42 #[arg(long = "lcov-version", value_parser = parse_lcov_version)]
54 lcov_version_cli: Option<Version>,
55
56 #[arg(skip = Version::new(1, 0, 0))]
58 lcov_version: Version,
59
60 #[arg(long)]
65 ir_minimum: bool,
66
67 #[arg(
71 long,
72 value_hint = ValueHint::FilePath,
73 value_name = "PATH"
74 )]
75 report_file: Option<PathBuf>,
76
77 #[arg(long)]
79 include_libs: bool,
80
81 #[arg(long)]
83 exclude_tests: bool,
84
85 #[arg(skip)]
87 reporters: Vec<Box<dyn CoverageReporter>>,
88
89 #[arg(skip)]
93 skip_files: Vec<String>,
94
95 #[command(flatten)]
96 test: TestArgs,
97}
98
99impl CoverageArgs {
100 pub async fn run(mut self) -> Result<()> {
101 let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
102
103 if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
105 {
106 config = self.load_config()?;
108 }
109
110 if config.fuzz.seed.is_none() {
113 config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
114 }
115
116 self.resolve_with(&config.coverage);
119
120 let (paths, mut output) = {
121 let (project, output) = self.build(&config)?;
122 (project.paths, output)
123 };
124
125 self.populate_reporters(&paths.root);
126
127 sh_println!("Analysing contracts...")?;
128 let report = self.prepare(&paths, &mut output)?;
129
130 sh_println!("Running tests...")?;
131 self.collect(&paths.root, &output, report, config, evm_opts).await
132 }
133
134 fn resolve_with(&mut self, config: &CoverageConfig) {
143 if self.report.is_empty() {
144 self.report.clone_from(&config.report);
145 }
146 self.lcov_version =
147 self.lcov_version_cli.clone().unwrap_or_else(|| config.lcov_version.clone());
148 if !self.ir_minimum {
149 self.ir_minimum = config.ir_minimum;
150 }
151 if self.report_file.is_none() {
152 self.report_file.clone_from(&config.report_file);
153 }
154 if !self.include_libs {
155 self.include_libs = config.include_libs;
156 }
157 if !self.exclude_tests {
158 self.exclude_tests = config.exclude_tests;
159 }
160 self.skip_files.clone_from(&config.skip_files);
163 }
164
165 fn populate_reporters(&mut self, root: &Path) {
166 self.reporters = self
167 .report
168 .iter()
169 .map(|report_kind| match report_kind {
170 CoverageReportKind::Summary => {
171 Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>
172 }
173 CoverageReportKind::Lcov => {
174 let path =
175 root.join(self.report_file.as_deref().unwrap_or("lcov.info".as_ref()));
176 Box::new(LcovReporter::new(path, self.lcov_version.clone()))
177 }
178 CoverageReportKind::Bytecode => Box::new(BytecodeReporter::new(
179 root.to_path_buf(),
180 root.join("bytecode-coverage"),
181 )),
182 CoverageReportKind::Debug => Box::new(DebugReporter),
183 })
184 .collect::<Vec<_>>();
185 }
186
187 fn build(&self, config: &Config) -> Result<(Project, ProjectCompileOutput)> {
189 let mut project = config.ephemeral_project()?;
190
191 if self.ir_minimum {
192 sh_warn!(
193 "`--ir-minimum` enables `viaIR` with minimum optimization, \
194 which can result in inaccurate source mappings.\n\
195 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
196 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
197 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
198 )?;
199 } else {
200 sh_warn!(
201 "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
202 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
203 enables `viaIR` with minimum optimization resolving most of the errors.\n\
204 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
205 )?;
206 }
207
208 config.disable_optimizations(&mut project, self.ir_minimum);
209
210 let output = ProjectCompiler::new()
211 .dynamic_test_linking(config.dynamic_test_linking)
212 .compile(&project)?
213 .with_stripped_file_prefixes(project.root());
214
215 Ok((project, output))
216 }
217
218 #[instrument(name = "Coverage::prepare", skip_all)]
220 fn prepare(
221 &self,
222 project_paths: &ProjectPathsConfig,
223 output: &mut ProjectCompileOutput,
224 ) -> Result<CoverageReport> {
225 let mut report = CoverageReport::default();
226
227 output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
228 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
229 let _ = compiler.lower_asts();
230 }
231 convert_solar_errors(compiler.dcx())
232 })?;
233 let output = &*output;
234
235 let mut versioned_sources = HashMap::<Version, SourceFiles>::default();
237 for (path, source_file, version) in output.output().sources.sources_with_version() {
238 if path
240 .extension()
241 .and_then(|s| s.to_str())
242 .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
243 {
244 continue;
245 }
246
247 report.add_source(version.clone(), source_file.id as usize, path.clone());
248
249 if (!self.include_libs && project_paths.has_library_ancestor(path))
251 || (self.exclude_tests && project_paths.is_test(path))
252 {
253 continue;
254 }
255
256 let path = project_paths.root.join(path);
257 versioned_sources
258 .entry(version.clone())
259 .or_default()
260 .sources
261 .insert(source_file.id, path);
262 }
263
264 let artifacts: Vec<ArtifactData> = output
266 .artifact_ids()
267 .par_bridge() .filter_map(|(id, artifact)| {
269 let source_id = report.get_source_id(id.version.clone(), id.source.clone())?;
270 ArtifactData::new(&id, source_id, artifact)
271 })
272 .collect();
273
274 for (version, sources) in &versioned_sources {
276 let source_analysis = SourceAnalysis::new(sources, output)?;
277 let anchors = artifacts
278 .par_iter()
279 .filter(|artifact| artifact.contract_id.version == *version)
280 .map(|artifact| {
281 let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
282 let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
283 (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
284 })
285 .collect_vec_list();
286 report.add_anchors(anchors.into_iter().flatten());
287 report.add_analysis(version.clone(), source_analysis);
288 }
289
290 if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
291 report.add_source_maps(artifacts.into_iter().map(|artifact| {
292 (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
293 }));
294 }
295
296 Ok(report)
297 }
298
299 #[instrument(name = "Coverage::collect", skip_all)]
301 async fn collect(
302 mut self,
303 project_root: &Path,
304 output: &ProjectCompileOutput,
305 mut report: CoverageReport,
306 config: Config,
307 evm_opts: EvmOpts,
308 ) -> Result<()> {
309 let filter = self.test.filter(&config)?;
310 let outcome =
311 self.test.run_tests(project_root, config, evm_opts, output, &filter, true).await?;
312
313 let known_contracts = outcome.known_contracts.as_ref().unwrap().clone();
314
315 let data = outcome.results.values().flat_map(|suite| {
317 let mut hits = Vec::new();
318 for result in suite.test_results.values() {
319 let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
320 for map in hit_maps.0.values() {
321 if let Some((id, _)) = known_contracts.find_by_deployed_code(map.bytecode()) {
322 hits.push((id, map, true));
323 } else if let Some((id, _)) =
324 known_contracts.find_by_creation_code(map.bytecode())
325 {
326 hits.push((id, map, false));
327 }
328 }
329 }
330 hits
331 });
332
333 for (artifact_id, map, is_deployed_code) in data {
334 if let Some(source_id) =
335 report.get_source_id(artifact_id.version.clone(), artifact_id.source.clone())
336 {
337 report.add_hit_map(
338 &ContractId {
339 version: artifact_id.version.clone(),
340 source_id,
341 contract_name: artifact_id.name.as_str().into(),
342 },
343 map,
344 is_deployed_code,
345 )?;
346 }
347 }
348
349 let file_root = filter.paths().root.as_path();
351 if let Some(not_re) = &filter.args().coverage_pattern_inverse {
352 report.retain_sources(|path: &Path| {
353 let path = path.strip_prefix(file_root).unwrap_or(path);
354 !not_re.is_match(&path.to_string_lossy())
355 });
356 }
357 if !self.skip_files.is_empty() {
358 let mut builder = GlobSetBuilder::new();
359 for pattern in &self.skip_files {
360 let glob = Glob::new(pattern).map_err(|e| {
361 eyre::eyre!("invalid glob in coverage.skip_files: '{pattern}': {e}")
362 })?;
363 builder.add(glob);
364 }
365 let set = builder
366 .build()
367 .map_err(|e| eyre::eyre!("failed to build coverage.skip_files glob set: {e}"))?;
368 report.retain_sources(|path: &Path| {
369 let path = path.strip_prefix(file_root).unwrap_or(path);
370 !set.is_match(path)
371 });
372 }
373
374 self.report(&report)?;
376
377 outcome.ensure_ok(false)?;
380
381 Ok(())
382 }
383
384 #[instrument(name = "Coverage::report", skip_all)]
385 fn report(&mut self, report: &CoverageReport) -> Result<()> {
386 for reporter in &mut self.reporters {
387 let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
388 reporter.report(report)?;
389 }
390 Ok(())
391 }
392
393 pub const fn is_watch(&self) -> bool {
394 self.test.is_watch()
395 }
396
397 pub const fn watch(&self) -> &WatchArgs {
398 &self.test.watch
399 }
400}
401
402fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
406 let link_references = obj.link_references.clone();
407 for (file, libraries) in link_references {
408 for library in libraries.keys() {
409 obj.link(&file, library, Address::ZERO);
410 }
411 }
412
413 obj.object.resolve();
414 obj.object.into_bytes()
415}
416
417fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
421 obj.bytecode.and_then(dummy_link_bytecode)
422}
423
424pub struct ArtifactData {
425 pub contract_id: ContractId,
426 pub creation: BytecodeData,
427 pub deployed: BytecodeData,
428}
429
430impl ArtifactData {
431 pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
432 Some(Self {
433 contract_id: ContractId {
434 version: id.version.clone(),
435 source_id,
436 contract_name: id.name.as_str().into(),
437 },
438 creation: BytecodeData::new(
439 artifact.get_source_map()?.ok()?,
440 artifact
441 .get_bytecode()
442 .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
443 ),
444 deployed: BytecodeData::new(
445 artifact.get_source_map_deployed()?.ok()?,
446 artifact
447 .get_deployed_bytecode()
448 .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
449 ),
450 })
451 }
452}
453
454pub struct BytecodeData {
455 source_map: SourceMap,
456 bytecode: Bytes,
457 ic_pc_map: IcPcMap,
465}
466
467impl BytecodeData {
468 fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
469 let ic_pc_map = IcPcMap::new(&bytecode);
470 Self { source_map, bytecode, ic_pc_map }
471 }
472
473 pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> Vec<ItemAnchor> {
474 find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn lcov_version() {
484 assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
485 assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
486 assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
487 assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
488 assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
489 }
490
491 #[test]
492 fn resolve_lcov_version_uses_config_when_cli_absent() {
493 let mut args = CoverageArgs::parse_from(["coverage"]);
494 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
495
496 args.resolve_with(&config);
497
498 assert_eq!(args.lcov_version, Version::new(2, 2, 0));
499 }
500
501 #[test]
502 fn resolve_lcov_version_keeps_explicit_cli_default() {
503 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "1"]);
504 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
505
506 args.resolve_with(&config);
507
508 assert_eq!(args.lcov_version, Version::new(1, 0, 0));
509 }
510
511 #[test]
512 fn resolve_lcov_version_keeps_explicit_cli_value() {
513 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "2"]);
514 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
515
516 args.resolve_with(&config);
517
518 assert_eq!(args.lcov_version, Version::new(2, 0, 0));
519 }
520}