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