Skip to main content

anchor_cli/
coverage.rs

1//! `anchor coverage` — generates LCOV source-level coverage from SBF register
2//! traces. Reuses the debugger's DWARF resolution to map executed PCs to
3//! source lines.
4//!
5//! Trace collection uses litesvm's stock `register-tracing` feature (no forked
6//! dependencies). Programs must be built with `CARGO_PROFILE_RELEASE_DEBUG=2`
7//! to include DWARF in the unstripped `.so`.
8
9use {
10    crate::{
11        debugger::source::SourceResolver,
12        flamegraph::trace::{find_unstripped_binary, REGS_ENTRY_SIZE},
13    },
14    anyhow::{anyhow, Context, Result},
15    std::{
16        collections::{BTreeMap, BTreeSet},
17        fs,
18        io::Write,
19        path::{Path, PathBuf},
20    },
21};
22
23/// Generate an LCOV file from register trace data.
24///
25/// `trace_dir` — directory containing `.regs` files (litesvm `SBF_TRACE_DIR`).
26/// `programs` — map of program_id (base58) → deployed `.so` path (from
27/// `discover_programs`). The unstripped version is resolved automatically
28/// via [`find_unstripped_binary`].
29/// `manifest_dir` — workspace manifest dir; used to (1) locate unstripped
30/// binaries and (2) resolve relative source paths emitted by DWARF (Solana's
31/// cargo passes `-Zremap-cwd-prefix=` which strips `DW_AT_comp_dir`, so
32/// paths come back as `lang-v2/src/cpi.rs` rather than absolute).
33/// `output` — path to write the LCOV file.
34///
35/// Emitted entries are filtered to files that actually exist on disk. This
36/// drops phantom paths from dependency crates (e.g. pinocchio's bare
37/// `src/de/mod.rs`) that can't be resolved without per-crate context.
38pub fn generate_lcov(
39    trace_dir: &Path,
40    programs: &BTreeMap<String, PathBuf>,
41    manifest_dir: Option<&Path>,
42    output: &Path,
43) -> Result<()> {
44    let pc_sets = collect_pcs_from_traces(trace_dir)?;
45    if pc_sets.is_empty() {
46        return Err(anyhow!("no trace data found in {}", trace_dir.display()));
47    }
48
49    eprintln!("found {} program(s) in traces", pc_sets.len());
50
51    let mut line_hits: BTreeMap<PathBuf, BTreeMap<u32, u64>> = BTreeMap::new();
52
53    for (program_id, pcs) in &pc_sets {
54        let deployed = match programs.get(program_id) {
55            Some(p) => p,
56            None => {
57                eprintln!("warning: no .so found for program {program_id}, skipping");
58                continue;
59            }
60        };
61
62        // DWARF lives in the unstripped sibling at
63        // `<workspace_root>/target/sbpf-solana-solana/release/<name>.so`.
64        // `find_unstripped_binary` walks up from `manifest_dir` to locate it
65        // deterministically (no guessing, no SHA matching).
66        let dwarf_path = find_unstripped_binary(deployed, manifest_dir)
67            .unwrap_or_else(|| deployed.to_path_buf());
68
69        let resolver = SourceResolver::from_elf_path(&dwarf_path);
70        if resolver.is_empty() {
71            eprintln!(
72                "warning: no DWARF in {} — rebuild with CARGO_PROFILE_RELEASE_DEBUG=2",
73                dwarf_path.display()
74            );
75            continue;
76        }
77
78        for loc in resolver.executable_lines() {
79            if let Some(path) = resolve_source_path(&loc.file, manifest_dir) {
80                line_hits
81                    .entry(path)
82                    .or_default()
83                    .entry(loc.line)
84                    .or_insert(0);
85            }
86        }
87
88        // Walk the full DWARF inlining chain per PC so `#[inline(always)]`
89        // wrappers get direct coverage credit. `find_location` alone would
90        // attribute the PC to whichever line the line program emits —
91        // usually one frame, sometimes the outer callsite — leaving tiny
92        // helpers like `Box<T>::load` and `AccountLoader::next*` at 0%
93        // despite running on every transaction. Matches the behavior of
94        // `llvm-cov show` over compile-time expansion regions.
95        let mut resolved_count = 0u64;
96        for &pc in pcs {
97            let frames = resolver.resolve_frames(pc);
98            if !frames.is_empty() {
99                resolved_count += 1;
100            }
101            for loc in frames {
102                if let Some(path) = resolve_source_path(&loc.file, manifest_dir) {
103                    *line_hits
104                        .entry(path)
105                        .or_default()
106                        .entry(loc.line)
107                        .or_insert(0) += 1;
108                }
109            }
110        }
111        eprintln!(
112            "  {} — {} unique PCs, {} resolved to source",
113            dwarf_path.file_name().unwrap_or_default().to_string_lossy(),
114            pcs.len(),
115            resolved_count,
116        );
117    }
118
119    if line_hits.is_empty() {
120        return Err(anyhow!(
121            "no source lines resolved from trace data in {}",
122            trace_dir.display()
123        ));
124    }
125
126    let summary = write_lcov_records(&line_hits, output)?;
127    eprintln!(
128        "  {} source files, {}/{} lines hit",
129        summary.files, summary.hit_lines, summary.total_lines
130    );
131    Ok(())
132}
133
134#[derive(Debug, PartialEq, Eq)]
135struct LcovSummary {
136    files: usize,
137    total_lines: usize,
138    hit_lines: usize,
139}
140
141fn write_lcov_records(
142    line_hits: &BTreeMap<PathBuf, BTreeMap<u32, u64>>,
143    output: &Path,
144) -> Result<LcovSummary> {
145    let mut out =
146        fs::File::create(output).with_context(|| format!("create {}", output.display()))?;
147
148    let mut summary = LcovSummary {
149        files: line_hits.len(),
150        total_lines: 0,
151        hit_lines: 0,
152    };
153
154    for (file, lines) in line_hits {
155        writeln!(out, "SF:{}", file.display())?;
156        for (&line, &hits) in lines {
157            writeln!(out, "DA:{line},{hits}")?;
158        }
159        let lf = lines.len();
160        let lh = lines.values().filter(|&&h| h > 0).count();
161        writeln!(out, "LF:{lf}")?;
162        writeln!(out, "LH:{lh}")?;
163        writeln!(out, "end_of_record")?;
164
165        summary.total_lines += lf;
166        summary.hit_lines += lh;
167    }
168
169    Ok(summary)
170}
171
172/// Resolve a DWARF-emitted source path to an absolute path that exists on
173/// disk. Returns `None` if the file can't be found.
174///
175/// Solana's cargo passes `-Zremap-cwd-prefix=` which strips `DW_AT_comp_dir`,
176/// so DWARF paths come back as either:
177///   - absolute (e.g. `/Users/runner/...` for stdlib baked at CI-build time)
178///   - relative to the invocation cwd (e.g. `lang-v2/src/cpi.rs` when `cargo
179///     build-sbf` was invoked from the workspace root)
180///   - bare relative `src/foo.rs` from dep crates — these can't be resolved
181///     without per-crate context and are dropped.
182fn resolve_source_path(file: &Path, workspace_root: Option<&Path>) -> Option<PathBuf> {
183    if file.is_absolute() {
184        return file.exists().then(|| file.to_path_buf());
185    }
186    let root = workspace_root?;
187    let candidate = root.join(file);
188    candidate.exists().then_some(candidate)
189}
190
191/// Walk trace directory recursively, collecting all unique PCs (reg[11])
192/// per program_id from `.regs` files.
193///
194/// Handles both trace-dir layouts:
195///   - flat `<dir>/<hash>.regs` (litesvm's `SBF_TRACE_DIR`)
196///   - nested `<dir>/<test_name>/<inv>__tx<N>.regs` (anchor-v2-testing's
197///     `ANCHOR_PROFILE_DIR`, used by `anchor debugger`)
198fn collect_pcs_from_traces(trace_dir: &Path) -> Result<BTreeMap<String, BTreeSet<u64>>> {
199    let mut result: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
200
201    if !trace_dir.exists() {
202        return Ok(result);
203    }
204
205    visit_dir(trace_dir, &mut result)?;
206    Ok(result)
207}
208
209fn visit_dir(dir: &Path, result: &mut BTreeMap<String, BTreeSet<u64>>) -> Result<()> {
210    for entry in fs::read_dir(dir)? {
211        let entry = entry?;
212        let path = entry.path();
213
214        if path.is_dir() {
215            visit_dir(&path, result)?;
216            continue;
217        }
218
219        if path.extension().and_then(|e| e.to_str()) != Some("regs") {
220            continue;
221        }
222
223        let pid_path = path.with_extension("program_id");
224        let program_id = match fs::read_to_string(&pid_path) {
225            Ok(s) => s.trim().to_string(),
226            Err(_) => continue,
227        };
228
229        let data = fs::read(&path)?;
230        if data.len() % REGS_ENTRY_SIZE != 0 {
231            eprintln!(
232                "warning: {} has unexpected size (not multiple of {})",
233                path.display(),
234                REGS_ENTRY_SIZE
235            );
236            continue;
237        }
238
239        let pcs = result.entry(program_id).or_default();
240        let num_steps = data.len() / REGS_ENTRY_SIZE;
241        for i in 0..num_steps {
242            let offset = i * REGS_ENTRY_SIZE + 11 * 8;
243            let pc = u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap());
244            pcs.insert(pc);
245        }
246    }
247    Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252    use {super::*, tempfile::tempdir};
253
254    #[test]
255    fn generate_lcov_errors_when_no_trace_data_found() {
256        let dir = tempdir().unwrap();
257        let output = dir.path().join("lcov.info");
258        let err =
259            generate_lcov(dir.path(), &BTreeMap::new(), None, &output).expect_err("expected error");
260
261        assert!(err.to_string().contains("no trace data found"));
262        assert!(!output.exists());
263    }
264
265    #[test]
266    fn lcov_writer_preserves_zero_hit_lines() {
267        let dir = tempdir().unwrap();
268        let source = dir.path().join("program.rs");
269        let output = dir.path().join("lcov.info");
270        let line_hits =
271            BTreeMap::from([(source.clone(), BTreeMap::from([(10, 3), (11, 0), (12, 1)]))]);
272
273        let summary = write_lcov_records(&line_hits, &output).unwrap();
274        let lcov = fs::read_to_string(output).unwrap();
275
276        assert_eq!(
277            summary,
278            LcovSummary {
279                files: 1,
280                total_lines: 3,
281                hit_lines: 2,
282            }
283        );
284        assert!(lcov.contains(&format!("SF:{}", source.display())));
285        assert!(lcov.contains("DA:10,3\n"));
286        assert!(lcov.contains("DA:11,0\n"));
287        assert!(lcov.contains("DA:12,1\n"));
288        assert!(lcov.contains("LF:3\n"));
289        assert!(lcov.contains("LH:2\n"));
290    }
291}