forge/cmd/
coverage.rs

1use super::{install, test::TestArgs, watch::WatchArgs};
2use crate::{
3    MultiContractRunnerBuilder,
4    coverage::{
5        BytecodeReporter, ContractId, CoverageReport, CoverageReporter, CoverageSummaryReporter,
6        DebugReporter, ItemAnchor, LcovReporter,
7        analysis::{SourceAnalysis, SourceFiles},
8        anchors::find_anchors,
9    },
10};
11use alloy_primitives::{Address, Bytes, U256, map::HashMap};
12use clap::{Parser, ValueEnum, ValueHint};
13use eyre::Result;
14use foundry_cli::utils::{LoadConfig, STATIC_FUZZ_SEED};
15use foundry_common::{compile::ProjectCompiler, errors::convert_solar_errors};
16use foundry_compilers::{
17    Artifact, ArtifactId, Project, ProjectCompileOutput, ProjectPathsConfig,
18    artifacts::{CompactBytecode, CompactDeployedBytecode, SolcLanguage, sourcemap::SourceMap},
19    compilers::multi::MultiCompiler,
20};
21use foundry_config::Config;
22use foundry_evm::opts::EvmOpts;
23use foundry_evm_core::ic::IcPcMap;
24use rayon::prelude::*;
25use semver::{Version, VersionReq};
26use std::{
27    path::{Path, PathBuf},
28    sync::Arc,
29};
30
31// Loads project's figment and merges the build cli arguments into it
32foundry_config::impl_figment_convert!(CoverageArgs, test);
33
34/// CLI arguments for `forge coverage`.
35#[derive(Parser)]
36pub struct CoverageArgs {
37    /// The report type to use for coverage.
38    ///
39    /// This flag can be used multiple times.
40    #[arg(long, value_enum, default_value = "summary")]
41    report: Vec<CoverageReportKind>,
42
43    /// The version of the LCOV "tracefile" format to use.
44    ///
45    /// Format: `MAJOR[.MINOR]`.
46    ///
47    /// Main differences:
48    /// - `1.x`: The original v1 format.
49    /// - `2.0`: Adds support for "line end" numbers for functions.
50    /// - `2.2`: Changes the format of functions.
51    #[arg(long, default_value = "1", value_parser = parse_lcov_version)]
52    lcov_version: Version,
53
54    /// Enable viaIR with minimum optimization
55    ///
56    /// This can fix most of the "stack too deep" errors while resulting a
57    /// relatively accurate source map.
58    #[arg(long)]
59    ir_minimum: bool,
60
61    /// The path to output the report.
62    ///
63    /// If not specified, the report will be stored in the root of the project.
64    #[arg(
65        long,
66        short,
67        value_hint = ValueHint::FilePath,
68        value_name = "PATH"
69    )]
70    report_file: Option<PathBuf>,
71
72    /// Whether to include libraries in the coverage report.
73    #[arg(long)]
74    include_libs: bool,
75
76    /// Whether to exclude tests from the coverage report.
77    #[arg(long)]
78    exclude_tests: bool,
79
80    /// The coverage reporters to use. Constructed from the other fields.
81    #[arg(skip)]
82    reporters: Vec<Box<dyn CoverageReporter>>,
83
84    #[command(flatten)]
85    test: TestArgs,
86}
87
88impl CoverageArgs {
89    pub async fn run(mut self) -> Result<()> {
90        let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
91
92        // install missing dependencies
93        if install::install_missing_dependencies(&mut config) && config.auto_detect_remappings {
94            // need to re-configure here to also catch additional remappings
95            config = self.load_config()?;
96        }
97
98        // Set fuzz seed so coverage reports are deterministic
99        config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
100
101        let (paths, mut output) = {
102            let (project, output) = self.build(&config)?;
103            (project.paths, output)
104        };
105
106        self.populate_reporters(&paths.root);
107
108        sh_println!("Analysing contracts...")?;
109        let report = self.prepare(&paths, &mut output)?;
110
111        sh_println!("Running tests...")?;
112        self.collect(&paths.root, &output, report, Arc::new(config), evm_opts).await
113    }
114
115    fn populate_reporters(&mut self, root: &Path) {
116        self.reporters = self
117            .report
118            .iter()
119            .map(|report_kind| match report_kind {
120                CoverageReportKind::Summary => {
121                    Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>
122                }
123                CoverageReportKind::Lcov => {
124                    let path =
125                        root.join(self.report_file.as_deref().unwrap_or("lcov.info".as_ref()));
126                    Box::new(LcovReporter::new(path, self.lcov_version.clone()))
127                }
128                CoverageReportKind::Bytecode => Box::new(BytecodeReporter::new(
129                    root.to_path_buf(),
130                    root.join("bytecode-coverage"),
131                )),
132                CoverageReportKind::Debug => Box::new(DebugReporter),
133            })
134            .collect::<Vec<_>>();
135    }
136
137    /// Builds the project.
138    fn build(&self, config: &Config) -> Result<(Project, ProjectCompileOutput)> {
139        let mut project = config.ephemeral_project()?;
140
141        if self.ir_minimum {
142            // print warning message
143            sh_warn!(
144                "`--ir-minimum` enables `viaIR` with minimum optimization, \
145                 which can result in inaccurate source mappings.\n\
146                 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
147                 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
148                 See more: https://github.com/foundry-rs/foundry/issues/3357"
149            )?;
150
151            // Enable viaIR with minimum optimization: https://github.com/ethereum/solidity/issues/12533#issuecomment-1013073350
152            // And also in new releases of Solidity: https://github.com/ethereum/solidity/issues/13972#issuecomment-1628632202
153            project.settings.solc.settings =
154                project.settings.solc.settings.with_via_ir_minimum_optimization();
155
156            // Sanitize settings for solc 0.8.4 if version cannot be detected: https://github.com/foundry-rs/foundry/issues/9322
157            // But keep the EVM version: https://github.com/ethereum/solidity/issues/15775
158            let evm_version = project.settings.solc.evm_version;
159            let version = config.solc_version().unwrap_or_else(|| Version::new(0, 8, 4));
160            project.settings.solc.settings.sanitize(&version, SolcLanguage::Solidity);
161            project.settings.solc.evm_version = evm_version;
162        } else {
163            sh_warn!(
164                "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
165                 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
166                 enables `viaIR` with minimum optimization resolving most of the errors"
167            )?;
168
169            project.settings.solc.optimizer.disable();
170            project.settings.solc.optimizer.runs = None;
171            project.settings.solc.optimizer.details = None;
172            project.settings.solc.via_ir = None;
173        }
174
175        let output = ProjectCompiler::default()
176            .compile(&project)?
177            .with_stripped_file_prefixes(project.root());
178
179        Ok((project, output))
180    }
181
182    /// Builds the coverage report.
183    #[instrument(name = "Coverage::prepare", skip_all)]
184    fn prepare(
185        &self,
186        project_paths: &ProjectPathsConfig,
187        output: &mut ProjectCompileOutput,
188    ) -> Result<CoverageReport> {
189        let mut report = CoverageReport::default();
190
191        output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
192            if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
193                let _ = compiler.lower_asts();
194            }
195            convert_solar_errors(compiler.dcx())
196        })?;
197        let output = &*output;
198
199        // Collect source files.
200        let mut versioned_sources = HashMap::<Version, SourceFiles>::default();
201        for (path, source_file, version) in output.output().sources.sources_with_version() {
202            report.add_source(version.clone(), source_file.id as usize, path.clone());
203
204            // Filter out libs dependencies and tests.
205            if (!self.include_libs && project_paths.has_library_ancestor(path))
206                || (self.exclude_tests && project_paths.is_test(path))
207            {
208                continue;
209            }
210
211            let path = project_paths.root.join(path);
212            versioned_sources
213                .entry(version.clone())
214                .or_default()
215                .sources
216                .insert(source_file.id, path);
217        }
218
219        // Get source maps and bytecodes.
220        let artifacts: Vec<ArtifactData> = output
221            .artifact_ids()
222            .par_bridge() // This parses source maps, so we want to run it in parallel.
223            .filter_map(|(id, artifact)| {
224                let source_id = report.get_source_id(id.version.clone(), id.source.clone())?;
225                ArtifactData::new(&id, source_id, artifact)
226            })
227            .collect();
228
229        // Add coverage items.
230        for (version, sources) in &versioned_sources {
231            let source_analysis = SourceAnalysis::new(sources, output)?;
232            let anchors = artifacts
233                .par_iter()
234                .filter(|artifact| artifact.contract_id.version == *version)
235                .map(|artifact| {
236                    let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
237                    let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
238                    (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
239                })
240                .collect_vec_list();
241            report.add_anchors(anchors.into_iter().flatten());
242            report.add_analysis(version.clone(), source_analysis);
243        }
244
245        if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
246            report.add_source_maps(artifacts.into_iter().map(|artifact| {
247                (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
248            }));
249        }
250
251        Ok(report)
252    }
253
254    /// Runs tests, collects coverage data and generates the final report.
255    #[instrument(name = "Coverage::collect", skip_all)]
256    async fn collect(
257        mut self,
258        root: &Path,
259        output: &ProjectCompileOutput,
260        mut report: CoverageReport,
261        config: Arc<Config>,
262        evm_opts: EvmOpts,
263    ) -> Result<()> {
264        let verbosity = evm_opts.verbosity;
265
266        // Build the contract runner
267        let env = evm_opts.evm_env().await?;
268        let runner = MultiContractRunnerBuilder::new(config.clone())
269            .initial_balance(evm_opts.initial_balance)
270            .evm_spec(config.evm_spec_id())
271            .sender(evm_opts.sender)
272            .with_fork(evm_opts.get_fork(&config, env.clone()))
273            .set_coverage(true)
274            .build::<MultiCompiler>(root, output, env, evm_opts)?;
275
276        let known_contracts = runner.known_contracts.clone();
277
278        let filter = self.test.filter(&config)?;
279        let outcome = self.test.run_tests(runner, config, verbosity, &filter, output).await?;
280
281        outcome.ensure_ok(false)?;
282
283        // Add hit data to the coverage report
284        let data = outcome.results.iter().flat_map(|(_, suite)| {
285            let mut hits = Vec::new();
286            for result in suite.test_results.values() {
287                let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
288                for map in hit_maps.0.values() {
289                    if let Some((id, _)) = known_contracts.find_by_deployed_code(map.bytecode()) {
290                        hits.push((id, map, true));
291                    } else if let Some((id, _)) =
292                        known_contracts.find_by_creation_code(map.bytecode())
293                    {
294                        hits.push((id, map, false));
295                    }
296                }
297            }
298            hits
299        });
300
301        for (artifact_id, map, is_deployed_code) in data {
302            if let Some(source_id) =
303                report.get_source_id(artifact_id.version.clone(), artifact_id.source.clone())
304            {
305                report.add_hit_map(
306                    &ContractId {
307                        version: artifact_id.version.clone(),
308                        source_id,
309                        contract_name: artifact_id.name.as_str().into(),
310                    },
311                    map,
312                    is_deployed_code,
313                )?;
314            }
315        }
316
317        // Filter out ignored sources from the report.
318        if let Some(not_re) = &filter.args().coverage_pattern_inverse {
319            let file_root = filter.paths().root.as_path();
320            report.retain_sources(|path: &Path| {
321                let path = path.strip_prefix(file_root).unwrap_or(path);
322                !not_re.is_match(&path.to_string_lossy())
323            });
324        }
325
326        // Output final reports.
327        self.report(&report)?;
328
329        Ok(())
330    }
331
332    #[instrument(name = "Coverage::report", skip_all)]
333    fn report(&mut self, report: &CoverageReport) -> Result<()> {
334        for reporter in &mut self.reporters {
335            let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
336            reporter.report(report)?;
337        }
338        Ok(())
339    }
340
341    pub fn is_watch(&self) -> bool {
342        self.test.is_watch()
343    }
344
345    pub fn watch(&self) -> &WatchArgs {
346        &self.test.watch
347    }
348}
349
350/// Coverage reports to generate.
351#[derive(Clone, Debug, Default, ValueEnum)]
352pub enum CoverageReportKind {
353    #[default]
354    Summary,
355    Lcov,
356    Debug,
357    Bytecode,
358}
359
360/// Helper function that will link references in unlinked bytecode to the 0 address.
361///
362/// This is needed in order to analyze the bytecode for contracts that use libraries.
363fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
364    let link_references = obj.link_references.clone();
365    for (file, libraries) in link_references {
366        for library in libraries.keys() {
367            obj.link(&file, library, Address::ZERO);
368        }
369    }
370
371    obj.object.resolve();
372    obj.object.into_bytes()
373}
374
375/// Helper function that will link references in unlinked bytecode to the 0 address.
376///
377/// This is needed in order to analyze the bytecode for contracts that use libraries.
378fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
379    obj.bytecode.and_then(dummy_link_bytecode)
380}
381
382pub struct ArtifactData {
383    pub contract_id: ContractId,
384    pub creation: BytecodeData,
385    pub deployed: BytecodeData,
386}
387
388impl ArtifactData {
389    pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
390        Some(Self {
391            contract_id: ContractId {
392                version: id.version.clone(),
393                source_id,
394                contract_name: id.name.as_str().into(),
395            },
396            creation: BytecodeData::new(
397                artifact.get_source_map()?.ok()?,
398                artifact
399                    .get_bytecode()
400                    .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
401            ),
402            deployed: BytecodeData::new(
403                artifact.get_source_map_deployed()?.ok()?,
404                artifact
405                    .get_deployed_bytecode()
406                    .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
407            ),
408        })
409    }
410}
411
412pub struct BytecodeData {
413    source_map: SourceMap,
414    bytecode: Bytes,
415    /// The instruction counter to program counter mapping.
416    ///
417    /// The source maps are indexed by *instruction counters*, which are the indexes of
418    /// instructions in the bytecode *minus any push bytes*.
419    ///
420    /// Since our line coverage inspector collects hit data using program counters, the anchors
421    /// also need to be based on program counters.
422    ic_pc_map: IcPcMap,
423}
424
425impl BytecodeData {
426    fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
427        let ic_pc_map = IcPcMap::new(&bytecode);
428        Self { source_map, bytecode, ic_pc_map }
429    }
430
431    pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> Vec<ItemAnchor> {
432        find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
433    }
434}
435
436fn parse_lcov_version(s: &str) -> Result<Version, String> {
437    let vr = VersionReq::parse(&format!("={s}")).map_err(|e| e.to_string())?;
438    let [c] = &vr.comparators[..] else {
439        return Err("invalid version".to_string());
440    };
441    if c.op != semver::Op::Exact {
442        return Err("invalid version".to_string());
443    }
444    if !c.pre.is_empty() {
445        return Err("pre-releases are not supported".to_string());
446    }
447    Ok(Version::new(c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0)))
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn lcov_version() {
456        assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
457        assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
458        assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
459        assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
460        assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
461    }
462}