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, ValueEnum, 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;
18use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts};
19use rayon::prelude::*;
20use semver::{Version, VersionReq};
21use std::path::{Path, PathBuf};
22
23foundry_config::impl_figment_convert!(CoverageArgs, test);
25
26#[derive(Parser)]
28pub struct CoverageArgs {
29 #[arg(long, value_enum, default_value = "summary")]
33 report: Vec<CoverageReportKind>,
34
35 #[arg(long, default_value = "1", value_parser = parse_lcov_version)]
44 lcov_version: Version,
45
46 #[arg(long)]
51 ir_minimum: bool,
52
53 #[arg(
57 long,
58 value_hint = ValueHint::FilePath,
59 value_name = "PATH"
60 )]
61 report_file: Option<PathBuf>,
62
63 #[arg(long)]
65 include_libs: bool,
66
67 #[arg(long)]
69 exclude_tests: bool,
70
71 #[arg(skip)]
73 reporters: Vec<Box<dyn CoverageReporter>>,
74
75 #[command(flatten)]
76 test: TestArgs,
77}
78
79impl CoverageArgs {
80 pub async fn run(mut self) -> Result<()> {
81 let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
82
83 if install::install_missing_dependencies(&mut config).await && config.auto_detect_remappings
85 {
86 config = self.load_config()?;
88 }
89
90 if config.fuzz.seed.is_none() {
93 config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
94 }
95
96 let (paths, mut output) = {
97 let (project, output) = self.build(&config)?;
98 (project.paths, output)
99 };
100
101 self.populate_reporters(&paths.root);
102
103 sh_println!("Analysing contracts...")?;
104 let report = self.prepare(&paths, &mut output)?;
105
106 sh_println!("Running tests...")?;
107 self.collect(&paths.root, &output, report, config, evm_opts).await
108 }
109
110 fn populate_reporters(&mut self, root: &Path) {
111 self.reporters = self
112 .report
113 .iter()
114 .map(|report_kind| match report_kind {
115 CoverageReportKind::Summary => {
116 Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>
117 }
118 CoverageReportKind::Lcov => {
119 let path =
120 root.join(self.report_file.as_deref().unwrap_or("lcov.info".as_ref()));
121 Box::new(LcovReporter::new(path, self.lcov_version.clone()))
122 }
123 CoverageReportKind::Bytecode => Box::new(BytecodeReporter::new(
124 root.to_path_buf(),
125 root.join("bytecode-coverage"),
126 )),
127 CoverageReportKind::Debug => Box::new(DebugReporter),
128 })
129 .collect::<Vec<_>>();
130 }
131
132 fn build(&self, config: &Config) -> Result<(Project, ProjectCompileOutput)> {
134 let mut project = config.ephemeral_project()?;
135
136 if self.ir_minimum {
137 sh_warn!(
138 "`--ir-minimum` enables `viaIR` with minimum optimization, \
139 which can result in inaccurate source mappings.\n\
140 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
141 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
142 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
143 )?;
144 } else {
145 sh_warn!(
146 "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
147 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
148 enables `viaIR` with minimum optimization resolving most of the errors.\n\
149 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
150 )?;
151 }
152
153 config.disable_optimizations(&mut project, self.ir_minimum);
154
155 let output = ProjectCompiler::default()
156 .compile(&project)?
157 .with_stripped_file_prefixes(project.root());
158
159 Ok((project, output))
160 }
161
162 #[instrument(name = "Coverage::prepare", skip_all)]
164 fn prepare(
165 &self,
166 project_paths: &ProjectPathsConfig,
167 output: &mut ProjectCompileOutput,
168 ) -> Result<CoverageReport> {
169 let mut report = CoverageReport::default();
170
171 output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
172 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
173 let _ = compiler.lower_asts();
174 }
175 convert_solar_errors(compiler.dcx())
176 })?;
177 let output = &*output;
178
179 let mut versioned_sources = HashMap::<Version, SourceFiles>::default();
181 for (path, source_file, version) in output.output().sources.sources_with_version() {
182 if path
184 .extension()
185 .and_then(|s| s.to_str())
186 .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
187 {
188 continue;
189 }
190
191 report.add_source(version.clone(), source_file.id as usize, path.clone());
192
193 if (!self.include_libs && project_paths.has_library_ancestor(path))
195 || (self.exclude_tests && project_paths.is_test(path))
196 {
197 continue;
198 }
199
200 let path = project_paths.root.join(path);
201 versioned_sources
202 .entry(version.clone())
203 .or_default()
204 .sources
205 .insert(source_file.id, path);
206 }
207
208 let artifacts: Vec<ArtifactData> = output
210 .artifact_ids()
211 .par_bridge() .filter_map(|(id, artifact)| {
213 let source_id = report.get_source_id(id.version.clone(), id.source.clone())?;
214 ArtifactData::new(&id, source_id, artifact)
215 })
216 .collect();
217
218 for (version, sources) in &versioned_sources {
220 let source_analysis = SourceAnalysis::new(sources, output)?;
221 let anchors = artifacts
222 .par_iter()
223 .filter(|artifact| artifact.contract_id.version == *version)
224 .map(|artifact| {
225 let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
226 let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
227 (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
228 })
229 .collect_vec_list();
230 report.add_anchors(anchors.into_iter().flatten());
231 report.add_analysis(version.clone(), source_analysis);
232 }
233
234 if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
235 report.add_source_maps(artifacts.into_iter().map(|artifact| {
236 (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
237 }));
238 }
239
240 Ok(report)
241 }
242
243 #[instrument(name = "Coverage::collect", skip_all)]
245 async fn collect(
246 mut self,
247 project_root: &Path,
248 output: &ProjectCompileOutput,
249 mut report: CoverageReport,
250 config: Config,
251 evm_opts: EvmOpts,
252 ) -> Result<()> {
253 let filter = self.test.filter(&config)?;
254 let outcome =
255 self.test.run_tests(project_root, config, evm_opts, output, &filter, true).await?;
256
257 let known_contracts = outcome.known_contracts.as_ref().unwrap().clone();
258
259 let data = outcome.results.values().flat_map(|suite| {
261 let mut hits = Vec::new();
262 for result in suite.test_results.values() {
263 let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
264 for map in hit_maps.0.values() {
265 if let Some((id, _)) = known_contracts.find_by_deployed_code(map.bytecode()) {
266 hits.push((id, map, true));
267 } else if let Some((id, _)) =
268 known_contracts.find_by_creation_code(map.bytecode())
269 {
270 hits.push((id, map, false));
271 }
272 }
273 }
274 hits
275 });
276
277 for (artifact_id, map, is_deployed_code) in data {
278 if let Some(source_id) =
279 report.get_source_id(artifact_id.version.clone(), artifact_id.source.clone())
280 {
281 report.add_hit_map(
282 &ContractId {
283 version: artifact_id.version.clone(),
284 source_id,
285 contract_name: artifact_id.name.as_str().into(),
286 },
287 map,
288 is_deployed_code,
289 )?;
290 }
291 }
292
293 if let Some(not_re) = &filter.args().coverage_pattern_inverse {
295 let file_root = filter.paths().root.as_path();
296 report.retain_sources(|path: &Path| {
297 let path = path.strip_prefix(file_root).unwrap_or(path);
298 !not_re.is_match(&path.to_string_lossy())
299 });
300 }
301
302 self.report(&report)?;
304
305 outcome.ensure_ok(false)?;
308
309 Ok(())
310 }
311
312 #[instrument(name = "Coverage::report", skip_all)]
313 fn report(&mut self, report: &CoverageReport) -> Result<()> {
314 for reporter in &mut self.reporters {
315 let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
316 reporter.report(report)?;
317 }
318 Ok(())
319 }
320
321 pub const fn is_watch(&self) -> bool {
322 self.test.is_watch()
323 }
324
325 pub const fn watch(&self) -> &WatchArgs {
326 &self.test.watch
327 }
328}
329
330#[derive(Clone, Debug, Default, ValueEnum)]
332pub enum CoverageReportKind {
333 #[default]
334 Summary,
335 Lcov,
336 Debug,
337 Bytecode,
338}
339
340fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
344 let link_references = obj.link_references.clone();
345 for (file, libraries) in link_references {
346 for library in libraries.keys() {
347 obj.link(&file, library, Address::ZERO);
348 }
349 }
350
351 obj.object.resolve();
352 obj.object.into_bytes()
353}
354
355fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
359 obj.bytecode.and_then(dummy_link_bytecode)
360}
361
362pub struct ArtifactData {
363 pub contract_id: ContractId,
364 pub creation: BytecodeData,
365 pub deployed: BytecodeData,
366}
367
368impl ArtifactData {
369 pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
370 Some(Self {
371 contract_id: ContractId {
372 version: id.version.clone(),
373 source_id,
374 contract_name: id.name.as_str().into(),
375 },
376 creation: BytecodeData::new(
377 artifact.get_source_map()?.ok()?,
378 artifact
379 .get_bytecode()
380 .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
381 ),
382 deployed: BytecodeData::new(
383 artifact.get_source_map_deployed()?.ok()?,
384 artifact
385 .get_deployed_bytecode()
386 .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
387 ),
388 })
389 }
390}
391
392pub struct BytecodeData {
393 source_map: SourceMap,
394 bytecode: Bytes,
395 ic_pc_map: IcPcMap,
403}
404
405impl BytecodeData {
406 fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
407 let ic_pc_map = IcPcMap::new(&bytecode);
408 Self { source_map, bytecode, ic_pc_map }
409 }
410
411 pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> Vec<ItemAnchor> {
412 find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
413 }
414}
415
416fn parse_lcov_version(s: &str) -> Result<Version, String> {
417 let vr = VersionReq::parse(&format!("={s}")).map_err(|e| e.to_string())?;
418 let [c] = &vr.comparators[..] else {
419 return Err("invalid version".to_string());
420 };
421 if c.op != semver::Op::Exact {
422 return Err("invalid version".to_string());
423 }
424 if !c.pre.is_empty() {
425 return Err("pre-releases are not supported".to_string());
426 }
427 Ok(Version::new(c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0)))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn lcov_version() {
436 assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
437 assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
438 assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
439 assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
440 assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
441 }
442}