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::{bail, Context, Result},
15    std::{
16        collections::{BTreeMap, BTreeSet, HashMap},
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        eprintln!("warning: no trace data found in {}", trace_dir.display());
47        return Ok(());
48    }
49
50    eprintln!("found {} program(s) in traces", pc_sets.len());
51
52    let mut line_hits: HashMap<PathBuf, BTreeMap<u32, u64>> = HashMap::new();
53
54    for (program_id, pcs) in &pc_sets {
55        let deployed = match programs.get(program_id) {
56            Some(p) => p,
57            None => {
58                eprintln!("warning: no .so found for program {program_id}, skipping");
59                continue;
60            }
61        };
62
63        // DWARF lives in the unstripped sibling at
64        // `<workspace_root>/target/sbpf-solana-solana/release/<name>.so`.
65        // `find_unstripped_binary` walks up from `manifest_dir` to locate it
66        // deterministically (no guessing, no SHA matching).
67        let dwarf_path = find_unstripped_binary(deployed, manifest_dir)
68            .unwrap_or_else(|| deployed.to_path_buf());
69
70        let resolver = SourceResolver::from_elf_path(&dwarf_path);
71        if resolver.is_empty() {
72            eprintln!(
73                "warning: no DWARF in {} — rebuild with CARGO_PROFILE_RELEASE_DEBUG=2",
74                dwarf_path.display()
75            );
76            continue;
77        }
78
79        // Walk the full DWARF inlining chain per PC so `#[inline(always)]`
80        // wrappers get direct coverage credit. `find_location` alone would
81        // attribute the PC to whichever line the line program emits —
82        // usually one frame, sometimes the outer callsite — leaving tiny
83        // helpers like `Box<T>::load` and `AccountLoader::next*` at 0%
84        // despite running on every transaction. Matches the behavior of
85        // `llvm-cov show` over compile-time expansion regions.
86        let mut resolved_count = 0u64;
87        for &pc in pcs {
88            let frames = resolver.resolve_frames(pc);
89            if !frames.is_empty() {
90                resolved_count += 1;
91            }
92            for loc in frames {
93                if let Some(path) = resolve_source_path(&loc.file, manifest_dir) {
94                    *line_hits
95                        .entry(path)
96                        .or_default()
97                        .entry(loc.line)
98                        .or_insert(0) += 1;
99                }
100            }
101        }
102        eprintln!(
103            "  {} — {} unique PCs, {} resolved to source",
104            dwarf_path.file_name().unwrap_or_default().to_string_lossy(),
105            pcs.len(),
106            resolved_count,
107        );
108    }
109
110    // Write LCOV format.
111    let mut out =
112        fs::File::create(output).with_context(|| format!("create {}", output.display()))?;
113
114    let mut sorted_files: Vec<_> = line_hits.into_iter().collect();
115    sorted_files.sort_by(|a, b| a.0.cmp(&b.0));
116
117    let total_files = sorted_files.len();
118    let total_lines: usize = sorted_files.iter().map(|(_, l)| l.len()).sum();
119
120    for (file, lines) in &sorted_files {
121        writeln!(out, "SF:{}", file.display())?;
122        for (&line, &hits) in lines {
123            writeln!(out, "DA:{line},{hits}")?;
124        }
125        let lf = lines.len();
126        let lh = lines.values().filter(|&&h| h > 0).count();
127        writeln!(out, "LF:{lf}")?;
128        writeln!(out, "LH:{lh}")?;
129        writeln!(out, "end_of_record")?;
130    }
131
132    eprintln!("  {total_files} source files, {total_lines} lines covered");
133    Ok(())
134}
135
136/// Filter host-side LCOV before combining it with SBF trace coverage.
137///
138/// Host coverage links SBF-only program crates but does not execute their
139/// handlers natively, so it can report `DA:N,0` for source that SBF did execute.
140/// Keep genuinely uncovered executable zeroes, but drop host zeroes that are
141/// either contradicted by an SBF hit on the same line or known non-executable
142/// Rust source lines.
143pub fn filter_host_lcov(sbf_lcov: &Path, host_lcov: &Path, output: &Path) -> Result<()> {
144    let sbf_records = parse_lcov(sbf_lcov)?;
145    let host_records = parse_lcov(host_lcov)?;
146    let sbf_hit_lines = sbf_records
147        .iter()
148        .map(|record| {
149            (
150                record.path.clone(),
151                record
152                    .da_counts
153                    .iter()
154                    .filter_map(|(line, count)| (*count > 0).then_some((*line, *count)))
155                    .collect::<BTreeMap<_, _>>(),
156            )
157        })
158        .filter(|(_, lines)| !lines.is_empty())
159        .collect::<BTreeMap<_, _>>();
160    let source_suppression = build_source_suppression(&sbf_records)?;
161
162    let mut out = String::new();
163    let mut exact_suppressed = 0usize;
164    let mut source_suppressed = 0usize;
165    let mut function_hits_inferred = 0usize;
166
167    for record in &host_records {
168        let source_lines = source_suppression.get(&record.path);
169        let sbf_lines = sbf_hit_lines.get(&record.path);
170        let function_starts = parse_function_starts(record)?;
171        let function_ranges = parse_function_ranges(record, &function_starts)?;
172        let (filtered_lines, inferred) =
173            infer_sbf_function_hits(record, sbf_lines, &function_starts, &function_ranges)
174                .with_context(|| {
175                    format!("filter function coverage for {}", record.path.display())
176                })?;
177        function_hits_inferred += inferred;
178
179        for line in &filtered_lines {
180            if let Some((line_no, count)) = parse_da_line(line)? {
181                if count == 0 && sbf_lines.is_some_and(|lines| lines.contains_key(&line_no)) {
182                    exact_suppressed += 1;
183                    continue;
184                }
185                if count == 0
186                    && !sbf_lines.is_some_and(|lines| lines.contains_key(&line_no))
187                    && source_lines.is_some_and(|lines| lines.contains(&line_no))
188                {
189                    source_suppressed += 1;
190                    continue;
191                }
192            }
193            out.push_str(line);
194            out.push('\n');
195        }
196    }
197
198    if let Some(parent) = output.parent() {
199        fs::create_dir_all(parent)?;
200    }
201    fs::write(output, out).with_context(|| format!("write {}", output.display()))?;
202    eprintln!(
203        "filtered host zero-hit DA lines: {exact_suppressed} exact SBF line hits, \
204         {source_suppressed} non-executable Rust source lines, \
205         {function_hits_inferred} SBF-backed function hits"
206    );
207
208    Ok(())
209}
210
211#[derive(Debug)]
212struct LcovRecord {
213    path: PathBuf,
214    lines: Vec<String>,
215    da_counts: BTreeMap<u32, i64>,
216}
217
218fn parse_lcov(path: &Path) -> Result<Vec<LcovRecord>> {
219    let content = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
220    let mut records = Vec::new();
221    let mut current_path: Option<PathBuf> = None;
222    let mut current_lines = Vec::new();
223    let mut current_da = BTreeMap::new();
224
225    for line in content.lines() {
226        if let Some(sf) = line.strip_prefix("SF:") {
227            if current_path.is_some() {
228                bail!("{}: saw nested SF before end_of_record", path.display());
229            }
230            current_path = Some(PathBuf::from(sf));
231            current_lines = vec![line.to_owned()];
232            current_da = BTreeMap::new();
233            continue;
234        }
235
236        let Some(record_path) = &current_path else {
237            if !line.is_empty() {
238                bail!("{}: data before first SF: {line}", path.display());
239            }
240            continue;
241        };
242
243        current_lines.push(line.to_owned());
244        if let Some((line_no, count)) = parse_da_line(line)? {
245            *current_da.entry(line_no).or_insert(0) += count;
246        } else if line == "end_of_record" {
247            records.push(LcovRecord {
248                path: record_path.clone(),
249                lines: std::mem::take(&mut current_lines),
250                da_counts: std::mem::take(&mut current_da),
251            });
252            current_path = None;
253        }
254    }
255
256    if let Some(record_path) = current_path {
257        bail!(
258            "{}: missing end_of_record for {}",
259            path.display(),
260            record_path.display()
261        );
262    }
263
264    Ok(records)
265}
266
267fn parse_da_line(line: &str) -> Result<Option<(u32, i64)>> {
268    let Some(rest) = line.strip_prefix("DA:") else {
269        return Ok(None);
270    };
271    let mut parts = rest.split(',');
272    let line_no = parts
273        .next()
274        .context("DA line missing line number")?
275        .parse()
276        .with_context(|| format!("invalid DA line number: {line}"))?;
277    let count = parts
278        .next()
279        .context("DA line missing hit count")?
280        .parse()
281        .with_context(|| format!("invalid DA hit count: {line}"))?;
282    Ok(Some((line_no, count)))
283}
284
285fn parse_function_starts(record: &LcovRecord) -> Result<BTreeMap<String, u32>> {
286    let mut starts = BTreeMap::new();
287    for line in &record.lines {
288        let Some((line_no, name)) = parse_fn_line(line)? else {
289            continue;
290        };
291        if let Some(previous) = starts.insert(name.clone(), line_no) {
292            if previous != line_no {
293                bail!(
294                    "{}: function {name:?} appears at both line {previous} and line {line_no}",
295                    record.path.display()
296                );
297            }
298        }
299    }
300    Ok(starts)
301}
302
303#[derive(Debug, Clone)]
304struct FunctionRange {
305    signature_lines: BTreeSet<u32>,
306    executable_body_lines: BTreeSet<u32>,
307    terminal_ok_lines: BTreeSet<u32>,
308}
309
310fn parse_function_ranges(
311    record: &LcovRecord,
312    function_starts: &BTreeMap<String, u32>,
313) -> Result<BTreeMap<String, FunctionRange>> {
314    if record.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
315        return Ok(BTreeMap::new());
316    }
317
318    let source = match fs::read_to_string(&record.path) {
319        Ok(source) => source,
320        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
321        Err(err) => return Err(err).with_context(|| format!("read {}", record.path.display())),
322    };
323    let source_lines = source.lines().collect::<Vec<_>>();
324    let mut by_start = BTreeMap::<u32, Option<FunctionRange>>::new();
325    let mut ranges = BTreeMap::new();
326
327    for (name, &start) in function_starts {
328        let range = match by_start.get(&start) {
329            Some(range) => range.clone(),
330            None => {
331                let range = parse_function_range(&record.path, &source_lines, start)?;
332                by_start.insert(start, range.clone());
333                range
334            }
335        };
336        if let Some(range) = range {
337            ranges.insert(name.clone(), range);
338        }
339    }
340
341    Ok(ranges)
342}
343
344fn parse_function_range(
345    source_path: &Path,
346    source_lines: &[&str],
347    start: u32,
348) -> Result<Option<FunctionRange>> {
349    let Some(start_text) = start
350        .checked_sub(1)
351        .and_then(|idx| source_lines.get(idx as usize))
352        .map(|line| line.trim())
353    else {
354        bail!(
355            "{}:{start}: LCOV function start is past end of file",
356            source_path.display()
357        );
358    };
359    if start_text.starts_with("//") || !looks_like_rust_fn_line(start_text) {
360        return Ok(None);
361    }
362
363    let Some(body) = find_rust_function_body(source_path, source_lines, start)? else {
364        return Ok(None);
365    };
366    let signature_lines = signature_lines_for_body(source_path, source_lines, start, body)?;
367    let executable_body_lines = executable_body_lines(source_lines, body)?;
368    let terminal_ok_lines = terminal_ok_lines(source_lines, body);
369    Ok(Some(FunctionRange {
370        signature_lines,
371        executable_body_lines,
372        terminal_ok_lines,
373    }))
374}
375
376#[derive(Debug, Clone, Copy)]
377struct FunctionBody {
378    start_line: u32,
379    start_col: usize,
380    end_line: u32,
381    end_col: usize,
382}
383
384fn find_rust_function_body(
385    source_path: &Path,
386    source_lines: &[&str],
387    start: u32,
388) -> Result<Option<FunctionBody>> {
389    let mut paren_depth = 0i32;
390    let last = u32::min(source_lines.len() as u32, start + 80);
391    let mut body_start = None;
392
393    for line_no in start..=last {
394        let line = source_lines[(line_no - 1) as usize];
395        match scan_signature_line(line, paren_depth).with_context(|| {
396            format!(
397                "{}:{line_no}: invalid Rust function signature",
398                source_path.display()
399            )
400        })? {
401            SignatureScan::Continue(depth) => paren_depth = depth,
402            SignatureScan::Body { col, depth } => {
403                if depth != 0 {
404                    bail!(
405                        "{}:{line_no}: Rust function body started with nonzero paren depth {depth}",
406                        source_path.display()
407                    );
408                }
409                body_start = Some((line_no, col));
410                break;
411            }
412            SignatureScan::DeclarationEnd => return Ok(None),
413        }
414    }
415
416    let Some((start_line, start_col)) = body_start else {
417        bail!(
418            "{}:{start}: covered Rust fn line has no body brace within 80 lines",
419            source_path.display()
420        );
421    };
422
423    let (end_line, end_col) =
424        find_matching_body_brace(source_path, source_lines, start_line, start_col)?;
425    Ok(Some(FunctionBody {
426        start_line,
427        start_col,
428        end_line,
429        end_col,
430    }))
431}
432
433fn find_matching_body_brace(
434    source_path: &Path,
435    source_lines: &[&str],
436    start_line: u32,
437    start_col: usize,
438) -> Result<(u32, usize)> {
439    let mut state = RustLexState::default();
440    let mut depth = 1i32;
441
442    for line_no in start_line..=(source_lines.len() as u32) {
443        let line = source_lines[(line_no - 1) as usize];
444        let start_idx = if line_no == start_line {
445            start_col + 1
446        } else {
447            0
448        };
449        let code = code_char_indices_after(line, start_idx, &mut state);
450        for (idx, ch) in code {
451            match ch {
452                '{' => depth += 1,
453                '}' => {
454                    depth -= 1;
455                    if depth == 0 {
456                        return Ok((line_no, idx));
457                    }
458                    if depth < 0 {
459                        bail!(
460                            "{}:{line_no}: Rust function body brace depth went negative",
461                            source_path.display()
462                        );
463                    }
464                }
465                _ => {}
466            }
467        }
468    }
469
470    bail!(
471        "{}:{start_line}: Rust function body has no matching closing brace",
472        source_path.display()
473    )
474}
475
476fn executable_body_lines(source_lines: &[&str], body: FunctionBody) -> Result<BTreeSet<u32>> {
477    let mut lines = BTreeSet::new();
478    let mut state = RustLexState::default();
479
480    for line_no in body.start_line..=body.end_line {
481        let line = source_lines[(line_no - 1) as usize];
482        let start_idx = if line_no == body.start_line {
483            body.start_col + 1
484        } else {
485            0
486        };
487        let end_idx = if line_no == body.end_line {
488            body.end_col
489        } else {
490            line.len()
491        };
492        let code = code_chars_in_range(line, start_idx, end_idx, &mut state);
493        let stripped = code.trim();
494        if stripped.is_empty() || is_rust_delimiter_only(stripped) {
495            continue;
496        }
497        lines.insert(line_no);
498    }
499
500    Ok(lines)
501}
502
503fn signature_lines_for_body(
504    source_path: &Path,
505    source_lines: &[&str],
506    start: u32,
507    body: FunctionBody,
508) -> Result<BTreeSet<u32>> {
509    let mut lines = BTreeSet::new();
510
511    for line_no in start..=body.start_line {
512        let line = source_lines[(line_no - 1) as usize];
513        if line_no == body.start_line {
514            let suffix = strip_line_comment(&line[body.start_col + 1..]).trim();
515            if !suffix.is_empty() {
516                continue;
517            }
518        }
519
520        let stripped = strip_line_comment(line).trim();
521        if stripped.is_empty() {
522            continue;
523        }
524        if line_no != start && looks_executable(stripped) {
525            bail!(
526                "{}:{line_no}: refusing to suppress executable-looking line in covered \
527                 function signature: {line:?}",
528                source_path.display()
529            );
530        }
531        lines.insert(line_no);
532    }
533
534    Ok(lines)
535}
536
537fn terminal_ok_lines(source_lines: &[&str], body: FunctionBody) -> BTreeSet<u32> {
538    let mut lines = BTreeSet::new();
539
540    for line_no in (body.start_line..=body.end_line).rev() {
541        let line = source_lines[(line_no - 1) as usize];
542        let start_idx = if line_no == body.start_line {
543            body.start_col + 1
544        } else {
545            0
546        };
547        let end_idx = if line_no == body.end_line {
548            body.end_col
549        } else {
550            line.len()
551        };
552        let stripped = strip_line_comment(&line[start_idx..end_idx]).trim();
553        if stripped.is_empty() || is_rust_delimiter_only(stripped) {
554            continue;
555        }
556        if matches!(stripped, "Ok(())" | "Ok(());") {
557            lines.insert(line_no);
558        }
559        break;
560    }
561
562    lines
563}
564
565fn infer_sbf_function_hits(
566    record: &LcovRecord,
567    sbf_lines: Option<&BTreeMap<u32, i64>>,
568    function_starts: &BTreeMap<String, u32>,
569    function_ranges: &BTreeMap<String, FunctionRange>,
570) -> Result<(Vec<String>, usize)> {
571    let Some(sbf_lines) = sbf_lines else {
572        return Ok((record.lines.clone(), 0));
573    };
574    if function_starts.is_empty() {
575        return Ok((record.lines.clone(), 0));
576    }
577
578    let mut lines = Vec::with_capacity(record.lines.len());
579    let mut function_hits = BTreeMap::<String, i64>::new();
580    let mut inferred = 0usize;
581    let mut saw_fnf = false;
582    let mut saw_fnh = false;
583
584    for line in &record.lines {
585        if let Some((count, name)) = parse_fnda_line(line)? {
586            let start_line = function_starts.get(&name).with_context(|| {
587                format!(
588                    "{}: FNDA record references unknown function {name:?}",
589                    record.path.display()
590                )
591            })?;
592            let count = if count == 0
593                && function_was_hit_by_sbf(&name, *start_line, sbf_lines, function_ranges)
594            {
595                inferred += 1;
596                1
597            } else {
598                count
599            };
600            if function_hits.insert(name.clone(), count).is_some() {
601                bail!(
602                    "{}: duplicate FNDA record for function {name:?}",
603                    record.path.display()
604                );
605            }
606            lines.push(format!("FNDA:{count},{name}"));
607        } else {
608            match line.as_str() {
609                line if line.starts_with("FNF:") => {
610                    saw_fnf = true;
611                    lines.push(format!("FNF:{}", function_starts.len()));
612                }
613                line if line.starts_with("FNH:") => {
614                    saw_fnh = true;
615                    lines.push("__ANCHOR_COVERAGE_FNH__".to_owned());
616                }
617                _ => lines.push(line.clone()),
618            }
619        }
620    }
621
622    for name in function_starts.keys() {
623        if !function_hits.contains_key(name) {
624            bail!(
625                "{}: FN record for {name:?} has no matching FNDA hit count",
626                record.path.display()
627            );
628        }
629    }
630
631    let fnh = function_hits.values().filter(|count| **count > 0).count();
632    for line in &mut lines {
633        if line == "__ANCHOR_COVERAGE_FNH__" {
634            *line = format!("FNH:{fnh}");
635        }
636    }
637
638    if !saw_fnf || !saw_fnh {
639        let end_idx = lines
640            .iter()
641            .position(|line| line == "end_of_record")
642            .context("LCOV record missing end_of_record")?;
643        if !saw_fnf {
644            lines.insert(end_idx, format!("FNF:{}", function_starts.len()));
645        }
646        if !saw_fnh {
647            lines.insert(end_idx + usize::from(!saw_fnf), format!("FNH:{fnh}"));
648        }
649    }
650
651    Ok((lines, inferred))
652}
653
654fn function_was_hit_by_sbf(
655    name: &str,
656    start_line: u32,
657    sbf_lines: &BTreeMap<u32, i64>,
658    function_ranges: &BTreeMap<String, FunctionRange>,
659) -> bool {
660    if let Some(range) = function_ranges.get(name) {
661        return range
662            .executable_body_lines
663            .iter()
664            .any(|line| sbf_lines.contains_key(line));
665    }
666
667    sbf_lines.contains_key(&start_line)
668}
669
670fn parse_fn_line(line: &str) -> Result<Option<(u32, String)>> {
671    let Some(rest) = line.strip_prefix("FN:") else {
672        return Ok(None);
673    };
674    let (line_no, name) = rest
675        .split_once(',')
676        .with_context(|| format!("FN line missing function name: {line}"))?;
677    Ok(Some((
678        line_no
679            .parse()
680            .with_context(|| format!("invalid FN line number: {line}"))?,
681        name.to_owned(),
682    )))
683}
684
685fn parse_fnda_line(line: &str) -> Result<Option<(i64, String)>> {
686    let Some(rest) = line.strip_prefix("FNDA:") else {
687        return Ok(None);
688    };
689    let (count, name) = rest
690        .split_once(',')
691        .with_context(|| format!("FNDA line missing function name: {line}"))?;
692    Ok(Some((
693        count
694            .parse()
695            .with_context(|| format!("invalid FNDA hit count: {line}"))?,
696        name.to_owned(),
697    )))
698}
699
700#[derive(Default)]
701struct RustLexState {
702    block_comment_depth: usize,
703    string: Option<StringState>,
704    char_literal: bool,
705}
706
707enum StringState {
708    Normal { escaped: bool },
709    Raw { hashes: usize },
710}
711
712fn code_chars_in_range(
713    line: &str,
714    start_idx: usize,
715    end_idx: usize,
716    state: &mut RustLexState,
717) -> String {
718    code_char_indices_after(line, start_idx, state)
719        .into_iter()
720        .take_while(|(idx, _)| *idx < end_idx)
721        .map(|(_, ch)| ch)
722        .collect()
723}
724
725fn code_char_indices_after(
726    line: &str,
727    start_idx: usize,
728    state: &mut RustLexState,
729) -> Vec<(usize, char)> {
730    let mut out = Vec::new();
731    let bytes = line.as_bytes();
732    let mut idx = 0usize;
733
734    while idx < line.len() {
735        if idx < start_idx {
736            idx = next_char_boundary(line, idx);
737            continue;
738        }
739
740        if state.block_comment_depth > 0 {
741            if bytes.get(idx..idx + 2) == Some(b"/*") {
742                state.block_comment_depth += 1;
743                idx += 2;
744            } else if bytes.get(idx..idx + 2) == Some(b"*/") {
745                state.block_comment_depth -= 1;
746                idx += 2;
747            } else {
748                idx = next_char_boundary(line, idx);
749            }
750            continue;
751        }
752
753        if let Some(string) = &mut state.string {
754            match string {
755                StringState::Normal { escaped } => {
756                    let ch = line[idx..].chars().next().expect("valid char boundary");
757                    idx += ch.len_utf8();
758                    if *escaped {
759                        *escaped = false;
760                    } else if ch == '\\' {
761                        *escaped = true;
762                    } else if ch == '"' {
763                        state.string = None;
764                    }
765                }
766                StringState::Raw { hashes } => {
767                    if raw_string_closes_at(bytes, idx, *hashes) {
768                        idx += 1 + *hashes;
769                        state.string = None;
770                    } else {
771                        idx = next_char_boundary(line, idx);
772                    }
773                }
774            }
775            continue;
776        }
777
778        if state.char_literal {
779            let ch = line[idx..].chars().next().expect("valid char boundary");
780            idx += ch.len_utf8();
781            if ch == '\\' {
782                idx = next_char_boundary(line, idx);
783            } else if ch == '\'' {
784                state.char_literal = false;
785            }
786            continue;
787        }
788
789        if bytes.get(idx..idx + 2) == Some(b"//") {
790            break;
791        }
792        if bytes.get(idx..idx + 2) == Some(b"/*") {
793            state.block_comment_depth += 1;
794            idx += 2;
795            continue;
796        }
797        if let Some((prefix_len, hashes)) = raw_string_starts_at(bytes, idx) {
798            state.string = Some(StringState::Raw { hashes });
799            idx += prefix_len;
800            continue;
801        }
802        if bytes.get(idx) == Some(&b'"') || bytes.get(idx..idx + 2) == Some(b"b\"") {
803            state.string = Some(StringState::Normal { escaped: false });
804            idx += if bytes.get(idx) == Some(&b'b') { 2 } else { 1 };
805            continue;
806        }
807        if char_literal_starts_at(line, idx) {
808            state.char_literal = true;
809            idx += 1;
810            continue;
811        }
812
813        let ch = line[idx..].chars().next().expect("valid char boundary");
814        out.push((idx, ch));
815        idx += ch.len_utf8();
816    }
817
818    if state.char_literal {
819        // Rust char literals cannot legally span lines. Resetting here avoids
820        // treating a later lifetime annotation as part of a phantom char.
821        state.char_literal = false;
822    }
823    out
824}
825
826fn next_char_boundary(line: &str, idx: usize) -> usize {
827    match line[idx..].chars().next() {
828        Some(ch) => idx + ch.len_utf8(),
829        None => line.len(),
830    }
831}
832
833fn raw_string_starts_at(bytes: &[u8], idx: usize) -> Option<(usize, usize)> {
834    let mut pos = idx;
835    if bytes.get(pos) == Some(&b'b') {
836        pos += 1;
837    }
838    if bytes.get(pos) != Some(&b'r') {
839        return None;
840    }
841    pos += 1;
842    let mut hashes = 0usize;
843    while bytes.get(pos) == Some(&b'#') {
844        hashes += 1;
845        pos += 1;
846    }
847    if bytes.get(pos) != Some(&b'"') {
848        return None;
849    }
850    Some((pos + 1 - idx, hashes))
851}
852
853fn raw_string_closes_at(bytes: &[u8], idx: usize, hashes: usize) -> bool {
854    if bytes.get(idx) != Some(&b'"') {
855        return false;
856    }
857    (0..hashes).all(|offset| bytes.get(idx + 1 + offset) == Some(&b'#'))
858}
859
860fn char_literal_starts_at(line: &str, idx: usize) -> bool {
861    if line.as_bytes().get(idx) != Some(&b'\'') {
862        return false;
863    }
864    let before = line[..idx].chars().rev().find(|ch| !ch.is_whitespace());
865    if matches!(before, Some(ch) if ch == '&' || ch == '<' || ch == ',' || ch == '(' || ch == '[') {
866        return false;
867    }
868    line[idx + 1..].contains('\'')
869}
870
871fn build_source_suppression(records: &[LcovRecord]) -> Result<BTreeMap<PathBuf, BTreeSet<u32>>> {
872    let mut suppress = BTreeMap::new();
873    for record in records {
874        if record.path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
875            continue;
876        }
877        if !should_parse_rust_source_artifacts(&record.path) {
878            continue;
879        }
880
881        let hit_lines = record
882            .da_counts
883            .iter()
884            .filter_map(|(line, count)| (*count > 0).then_some(*line))
885            .collect::<BTreeSet<_>>();
886        let mut lines = delimiter_only_lines(&record.path)?;
887        if !hit_lines.is_empty() {
888            lines.extend(source_artifact_lines(&record.path, &hit_lines)?);
889        }
890        if !lines.is_empty() {
891            suppress.insert(record.path.clone(), lines);
892        }
893    }
894    Ok(suppress)
895}
896
897fn should_parse_rust_source_artifacts(path: &Path) -> bool {
898    let path = path.to_string_lossy();
899    !path.contains("/.cargo/") && !path.contains("/target/") && !path.contains("/rustc/")
900}
901
902fn delimiter_only_lines(source_path: &Path) -> Result<BTreeSet<u32>> {
903    let source = match fs::read_to_string(source_path) {
904        Ok(source) => source,
905        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
906        Err(err) => return Err(err).with_context(|| format!("read {}", source_path.display())),
907    };
908    Ok(source
909        .lines()
910        .enumerate()
911        .filter_map(|(idx, line)| {
912            is_rust_delimiter_only(strip_line_comment(line).trim()).then_some(idx as u32 + 1)
913        })
914        .collect())
915}
916
917fn source_artifact_lines(source_path: &Path, hit_lines: &BTreeSet<u32>) -> Result<BTreeSet<u32>> {
918    let source = match fs::read_to_string(source_path) {
919        Ok(source) => source,
920        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
921        Err(err) => return Err(err).with_context(|| format!("read {}", source_path.display())),
922    };
923    let source_lines = source.lines().collect::<Vec<_>>();
924    let mut suppress = BTreeSet::new();
925
926    for (line_idx, line) in source_lines.iter().enumerate() {
927        let line_no = line_idx as u32 + 1;
928        let start_text = line.trim();
929        if start_text.starts_with("//") || !looks_like_rust_fn_line(start_text) {
930            continue;
931        }
932
933        let Some(range) = parse_function_range(source_path, &source_lines, line_no)? else {
934            continue;
935        };
936        if function_range_was_hit_by_sbf(&range, line_no, hit_lines) {
937            suppress.extend(range.signature_lines);
938            suppress.extend(range.terminal_ok_lines);
939        }
940    }
941
942    suppress.extend(macro_continuation_lines(
943        source_path,
944        &source_lines,
945        hit_lines,
946    )?);
947
948    Ok(suppress)
949}
950
951fn function_range_was_hit_by_sbf(
952    range: &FunctionRange,
953    start_line: u32,
954    hit_lines: &BTreeSet<u32>,
955) -> bool {
956    hit_lines.contains(&start_line)
957        || range
958            .executable_body_lines
959            .iter()
960            .any(|line| hit_lines.contains(line))
961}
962
963fn macro_continuation_lines(
964    source_path: &Path,
965    source_lines: &[&str],
966    hit_lines: &BTreeSet<u32>,
967) -> Result<BTreeSet<u32>> {
968    let mut suppress = BTreeSet::new();
969
970    for &start in hit_lines {
971        let Some(line) = start
972            .checked_sub(1)
973            .and_then(|idx| source_lines.get(idx as usize))
974        else {
975            continue;
976        };
977        let Some((open_col, open, close)) = macro_open_delimiter(line) else {
978            continue;
979        };
980
981        let Some(end) =
982            find_matching_delimiter(source_path, source_lines, start, open_col, open, close)?
983        else {
984            continue;
985        };
986        if end <= start {
987            continue;
988        }
989
990        for line_no in (start + 1)..=end {
991            let line = source_lines[(line_no - 1) as usize];
992            let stripped = strip_line_comment(line).trim();
993            if stripped.is_empty() || is_rust_delimiter_only(stripped) {
994                continue;
995            }
996            if looks_executable(stripped) {
997                bail!(
998                    "{}:{line_no}: refusing to suppress executable-looking line in covered \
999                     macro continuation: {line:?}",
1000                    source_path.display()
1001                );
1002            }
1003            suppress.insert(line_no);
1004        }
1005    }
1006
1007    Ok(suppress)
1008}
1009
1010fn macro_open_delimiter(line: &str) -> Option<(usize, char, char)> {
1011    let bang = line.find('!')?;
1012    let before = line[..bang].chars().next_back()?;
1013    if !(before.is_ascii_alphanumeric() || before == '_') {
1014        return None;
1015    }
1016    let after_bang = &line[bang + 1..];
1017    let mut chars = after_bang
1018        .char_indices()
1019        .skip_while(|(_, ch)| ch.is_whitespace());
1020    let (offset, open) = chars.next()?;
1021    if !matches!(open, '(' | '[' | '{') {
1022        return None;
1023    }
1024    let close = match open {
1025        '(' => ')',
1026        '[' => ']',
1027        '{' => '}',
1028        _ => unreachable!(),
1029    };
1030    Some((bang + 1 + offset, open, close))
1031}
1032
1033fn find_matching_delimiter(
1034    source_path: &Path,
1035    source_lines: &[&str],
1036    start_line: u32,
1037    start_col: usize,
1038    open: char,
1039    close: char,
1040) -> Result<Option<u32>> {
1041    let mut state = RustLexState::default();
1042    let mut depth = 0i32;
1043
1044    for line_no in start_line..=(source_lines.len() as u32) {
1045        let line = source_lines[(line_no - 1) as usize];
1046        let start_idx = if line_no == start_line { start_col } else { 0 };
1047        for (_, ch) in code_char_indices_after(line, start_idx, &mut state) {
1048            if ch == open {
1049                depth += 1;
1050            } else if ch == close {
1051                depth -= 1;
1052                if depth == 0 {
1053                    return Ok(Some(line_no));
1054                }
1055                if depth < 0 {
1056                    bail!(
1057                        "{}:{line_no}: macro delimiter depth went negative",
1058                        source_path.display()
1059                    );
1060                }
1061            }
1062        }
1063    }
1064
1065    Ok(None)
1066}
1067
1068enum SignatureScan {
1069    Continue(i32),
1070    Body { col: usize, depth: i32 },
1071    DeclarationEnd,
1072}
1073
1074fn scan_signature_line(line: &str, paren_depth: i32) -> Result<SignatureScan> {
1075    let code = strip_line_comment(line);
1076    let mut depth = paren_depth;
1077    for (idx, ch) in code.char_indices() {
1078        match ch {
1079            '(' => depth += 1,
1080            ')' => {
1081                depth -= 1;
1082                if depth < 0 {
1083                    bail!("negative paren depth in signature line: {line:?}");
1084                }
1085            }
1086            '{' if depth == 0 => return Ok(SignatureScan::Body { col: idx, depth }),
1087            ';' if depth == 0 => return Ok(SignatureScan::DeclarationEnd),
1088            _ => {}
1089        }
1090    }
1091    Ok(SignatureScan::Continue(depth))
1092}
1093
1094fn strip_line_comment(line: &str) -> &str {
1095    line.split_once("//").map_or(line, |(code, _)| code)
1096}
1097
1098fn looks_like_rust_fn_line(line: &str) -> bool {
1099    line.contains("fn ") || line.contains("fn\t")
1100}
1101
1102fn looks_executable(line: &str) -> bool {
1103    ["let ", "return ", "?;", "if ", "match "]
1104        .iter()
1105        .any(|token| line.contains(token))
1106}
1107
1108fn is_rust_delimiter_only(line: &str) -> bool {
1109    matches!(
1110        line,
1111        ")" | ")," | ");" | "]" | "]," | "];" | "}" | "}," | "};"
1112    )
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use {super::*, std::fs, tempfile::tempdir};
1118
1119    #[test]
1120    fn filter_host_lcov_removes_only_non_executable_noise() {
1121        let tmp = tempdir().unwrap();
1122        let source = tmp.path().join("lib.rs");
1123        fs::write(
1124            &source,
1125            [
1126                "pub fn hit(",
1127                "    arg: u8,",
1128                ") -> Result<()> {",
1129                "    if arg == 0 {",
1130                "        return Err(());",
1131                "    }",
1132                "    Ok(())",
1133                "}",
1134                "",
1135            ]
1136            .join("\n"),
1137        )
1138        .unwrap();
1139
1140        let sbf = tmp.path().join("sbf.lcov");
1141        fs::write(
1142            &sbf,
1143            format!(
1144                "SF:{}\nDA:1,3\nDA:4,2\nDA:8,3\nend_of_record\n",
1145                source.display()
1146            ),
1147        )
1148        .unwrap();
1149
1150        let host = tmp.path().join("host.lcov");
1151        fs::write(
1152            &host,
1153            format!(
1154                "SF:{}\nDA:1,0\nDA:2,0\nDA:3,0\nDA:4,0\nDA:5,0\nDA:6,0\nDA:7,0\nDA:8,0\nend_of_record\n",
1155                source.display()
1156            ),
1157        )
1158        .unwrap();
1159
1160        let output = tmp.path().join("filtered.lcov");
1161        filter_host_lcov(&sbf, &host, &output).unwrap();
1162        let filtered = fs::read_to_string(output).unwrap();
1163
1164        assert!(
1165            !filtered.contains("DA:1,0\n"),
1166            "exact SBF hit should remove host zero"
1167        );
1168        assert!(
1169            !filtered.contains("DA:2,0\n"),
1170            "signature arg line is non-executable"
1171        );
1172        assert!(
1173            !filtered.contains("DA:3,0\n"),
1174            "signature terminator line is non-executable"
1175        );
1176        assert!(
1177            !filtered.contains("DA:4,0\n"),
1178            "exact SBF hit should remove host zero"
1179        );
1180        assert!(
1181            filtered.contains("DA:5,0\n"),
1182            "real return branch must stay uncovered"
1183        );
1184        assert!(
1185            !filtered.contains("DA:6,0\n"),
1186            "delimiter-only line is non-executable"
1187        );
1188        assert!(
1189            !filtered.contains("DA:7,0\n"),
1190            "trivial terminal Ok tail is a source attribution artifact"
1191        );
1192        assert!(
1193            !filtered.contains("DA:8,0\n"),
1194            "exact SBF hit should remove host zero"
1195        );
1196    }
1197
1198    #[test]
1199    fn filter_host_lcov_fails_closed_on_executable_signature_continuation() {
1200        let tmp = tempdir().unwrap();
1201        let source = tmp.path().join("lib.rs");
1202        fs::write(
1203            &source,
1204            ["pub fn suspicious(", "    let x = 1,", ") {", "}"].join("\n"),
1205        )
1206        .unwrap();
1207
1208        let sbf = tmp.path().join("sbf.lcov");
1209        fs::write(
1210            &sbf,
1211            format!("SF:{}\nDA:1,1\nend_of_record\n", source.display()),
1212        )
1213        .unwrap();
1214
1215        let host = tmp.path().join("host.lcov");
1216        fs::write(
1217            &host,
1218            format!("SF:{}\nDA:2,0\nend_of_record\n", source.display()),
1219        )
1220        .unwrap();
1221
1222        let output = tmp.path().join("filtered.lcov");
1223        let err = filter_host_lcov(&sbf, &host, &output).unwrap_err();
1224        assert!(
1225            err.to_string()
1226                .contains("refusing to suppress executable-looking line"),
1227            "unexpected error: {err:?}"
1228        );
1229    }
1230
1231    #[test]
1232    fn filter_host_lcov_suppresses_macro_continuation_lines_for_hit_macro() {
1233        let tmp = tempdir().unwrap();
1234        let source = tmp.path().join("lib.rs");
1235        fs::write(
1236            &source,
1237            [
1238                "pub fn check(data: &[u8]) -> Result<(), ProgramError> {",
1239                "    require_eq!(",
1240                "        data.len(),",
1241                "        core::mem::size_of::<Self>(),",
1242                "        ProgramError::InvalidAccountData",
1243                "    );",
1244                "    Ok(())",
1245                "}",
1246                "",
1247            ]
1248            .join("\n"),
1249        )
1250        .unwrap();
1251
1252        let sbf = tmp.path().join("sbf.lcov");
1253        fs::write(
1254            &sbf,
1255            format!("SF:{}\nDA:2,5\nend_of_record\n", source.display()),
1256        )
1257        .unwrap();
1258
1259        let host = tmp.path().join("host.lcov");
1260        fs::write(
1261            &host,
1262            format!(
1263                "SF:{}\nDA:1,0\nDA:2,0\nDA:3,0\nDA:4,0\nDA:5,0\nDA:6,0\nDA:7,0\nDA:8,0\nend_of_record\n",
1264                source.display()
1265            ),
1266        )
1267        .unwrap();
1268
1269        let output = tmp.path().join("filtered.lcov");
1270        filter_host_lcov(&sbf, &host, &output).unwrap();
1271        let filtered = fs::read_to_string(output).unwrap();
1272
1273        assert!(
1274            !filtered.contains("DA:3,0\n"),
1275            "macro argument line is not an independent uncovered line"
1276        );
1277        assert!(
1278            !filtered.contains("DA:4,0\n"),
1279            "macro argument line is not an independent uncovered line"
1280        );
1281        assert!(
1282            !filtered.contains("DA:5,0\n"),
1283            "macro error argument line is not branch coverage"
1284        );
1285    }
1286
1287    #[test]
1288    fn filter_host_lcov_fails_closed_on_executable_macro_continuation() {
1289        let tmp = tempdir().unwrap();
1290        let source = tmp.path().join("lib.rs");
1291        fs::write(
1292            &source,
1293            [
1294                "pub fn check() {",
1295                "    some_macro!(",
1296                "        if condition { value } else { other },",
1297                "    );",
1298                "}",
1299            ]
1300            .join("\n"),
1301        )
1302        .unwrap();
1303
1304        let sbf = tmp.path().join("sbf.lcov");
1305        fs::write(
1306            &sbf,
1307            format!("SF:{}\nDA:2,1\nend_of_record\n", source.display()),
1308        )
1309        .unwrap();
1310
1311        let host = tmp.path().join("host.lcov");
1312        fs::write(
1313            &host,
1314            format!("SF:{}\nDA:3,0\nend_of_record\n", source.display()),
1315        )
1316        .unwrap();
1317
1318        let output = tmp.path().join("filtered.lcov");
1319        let err = filter_host_lcov(&sbf, &host, &output).unwrap_err();
1320        assert!(
1321            err.to_string()
1322                .contains("refusing to suppress executable-looking line"),
1323            "unexpected error: {err:?}"
1324        );
1325    }
1326
1327    #[test]
1328    fn filter_host_lcov_infers_sbf_function_hits_from_executable_body_lines() {
1329        let tmp = tempdir().unwrap();
1330        let source = tmp.path().join("lib.rs");
1331        fs::write(
1332            &source,
1333            [
1334                "pub fn hit() {",
1335                "    do_work();",
1336                "}",
1337                "",
1338                "pub fn miss() {",
1339                "    do_work();",
1340                "}",
1341                "",
1342                "pub fn body_only() -> u64 {",
1343                "    u64::from_le_bytes([0; 8])",
1344                "}",
1345                "",
1346                "pub fn delimiter_only() {",
1347                "}",
1348                "",
1349            ]
1350            .join("\n"),
1351        )
1352        .unwrap();
1353
1354        let sbf = tmp.path().join("sbf.lcov");
1355        fs::write(
1356            &sbf,
1357            format!(
1358                "SF:{}\nDA:1,7\nDA:2,9\nDA:10,4\nDA:14,2\nend_of_record\n",
1359                source.display()
1360            ),
1361        )
1362        .unwrap();
1363
1364        let host = tmp.path().join("host.lcov");
1365        fs::write(
1366            &host,
1367            format!(
1368                "SF:{}\nFN:1,_hit\nFN:5,_miss\nFN:9,_body_only\nFN:13,_delimiter_only\nFNDA:0,_hit\nFNDA:0,_miss\nFNDA:0,_body_only\nFNDA:0,_delimiter_only\nFNF:4\nFNH:0\nDA:1,0\nDA:2,0\nDA:3,0\nDA:5,0\nDA:6,0\nDA:7,0\nDA:9,0\nDA:10,0\nDA:11,0\nDA:13,0\nDA:14,0\nend_of_record\n",
1369                source.display()
1370            ),
1371        )
1372        .unwrap();
1373
1374        let output = tmp.path().join("filtered.lcov");
1375        filter_host_lcov(&sbf, &host, &output).unwrap();
1376        let filtered = fs::read_to_string(output).unwrap();
1377
1378        assert!(
1379            filtered.contains("FNDA:1,_hit\n"),
1380            "function with an SBF-hit executable body line should be marked hit"
1381        );
1382        assert!(
1383            filtered.contains("FNDA:0,_miss\n"),
1384            "function without an SBF-hit body line must stay uncovered"
1385        );
1386        assert!(
1387            filtered.contains("FNDA:1,_body_only\n"),
1388            "function should be marked hit when SBF hits the body but not the fn line"
1389        );
1390        assert!(
1391            !filtered.contains("DA:9,0\n"),
1392            "function declaration line should be removed when SBF hits the body"
1393        );
1394        assert!(
1395            filtered.contains("FNDA:0,_delimiter_only\n"),
1396            "delimiter-only body hits must not create function hits"
1397        );
1398        assert!(filtered.contains("FNF:4\n"));
1399        assert!(filtered.contains("FNH:2\n"));
1400    }
1401
1402    #[test]
1403    fn filter_host_lcov_function_body_inference_does_not_leak_to_next_function() {
1404        let tmp = tempdir().unwrap();
1405        let source = tmp.path().join("lib.rs");
1406        fs::write(
1407            &source,
1408            [
1409                "pub fn first() {",
1410                "    not_hit();",
1411                "}",
1412                "",
1413                "pub fn second() {",
1414                "    hit();",
1415                "}",
1416                "",
1417            ]
1418            .join("\n"),
1419        )
1420        .unwrap();
1421
1422        let sbf = tmp.path().join("sbf.lcov");
1423        fs::write(
1424            &sbf,
1425            format!("SF:{}\nDA:6,3\nend_of_record\n", source.display()),
1426        )
1427        .unwrap();
1428
1429        let host = tmp.path().join("host.lcov");
1430        fs::write(
1431            &host,
1432            format!(
1433                "SF:{}\nFN:1,_first\nFN:5,_second\nFNDA:0,_first\nFNDA:0,_second\nFNF:2\nFNH:0\nDA:1,0\nDA:2,0\nDA:3,0\nDA:5,0\nDA:6,0\nDA:7,0\nend_of_record\n",
1434                source.display()
1435            ),
1436        )
1437        .unwrap();
1438
1439        let output = tmp.path().join("filtered.lcov");
1440        filter_host_lcov(&sbf, &host, &output).unwrap();
1441        let filtered = fs::read_to_string(output).unwrap();
1442
1443        assert!(
1444            filtered.contains("FNDA:0,_first\n"),
1445            "SBF hits in a later function must not mark the previous function hit"
1446        );
1447        assert!(
1448            filtered.contains("FNDA:1,_second\n"),
1449            "SBF body hit should mark the containing function hit"
1450        );
1451        assert!(filtered.contains("FNH:1\n"));
1452    }
1453
1454    #[test]
1455    fn filter_host_lcov_fails_closed_on_unknown_function_hit_record() {
1456        let tmp = tempdir().unwrap();
1457        let source = tmp.path().join("lib.rs");
1458        fs::write(&source, ["pub fn hit() {", "}"].join("\n")).unwrap();
1459
1460        let sbf = tmp.path().join("sbf.lcov");
1461        fs::write(
1462            &sbf,
1463            format!("SF:{}\nDA:1,1\nend_of_record\n", source.display()),
1464        )
1465        .unwrap();
1466
1467        let host = tmp.path().join("host.lcov");
1468        fs::write(
1469            &host,
1470            format!(
1471                "SF:{}\nFN:1,_hit\nFNDA:0,_hit\nFNDA:0,_unknown\nFNF:2\nFNH:0\nend_of_record\n",
1472                source.display()
1473            ),
1474        )
1475        .unwrap();
1476
1477        let output = tmp.path().join("filtered.lcov");
1478        let err = filter_host_lcov(&sbf, &host, &output).unwrap_err();
1479        assert!(
1480            format!("{err:?}").contains("unknown function"),
1481            "unexpected error: {err:?}"
1482        );
1483    }
1484}
1485
1486/// Resolve a DWARF-emitted source path to an absolute path that exists on
1487/// disk. Returns `None` if the file can't be found.
1488///
1489/// Solana's cargo passes `-Zremap-cwd-prefix=` which strips `DW_AT_comp_dir`,
1490/// so DWARF paths come back as either:
1491///   - absolute (e.g. `/Users/runner/...` for stdlib baked at CI-build time)
1492///   - relative to the invocation cwd (e.g. `lang-v2/src/cpi.rs` when `cargo
1493///     build-sbf` was invoked from the workspace root)
1494///   - bare relative `src/foo.rs` from dep crates — these can't be resolved
1495///     without per-crate context and are dropped.
1496fn resolve_source_path(file: &Path, workspace_root: Option<&Path>) -> Option<PathBuf> {
1497    if file.is_absolute() {
1498        return file.exists().then(|| file.to_path_buf());
1499    }
1500    let root = workspace_root?;
1501    let candidate = root.join(file);
1502    candidate.exists().then_some(candidate)
1503}
1504
1505/// Walk trace directory recursively, collecting all unique PCs (reg[11])
1506/// per program_id from `.regs` files.
1507///
1508/// Handles both trace-dir layouts:
1509///   - flat `<dir>/<hash>.regs` (litesvm's `SBF_TRACE_DIR`)
1510///   - nested `<dir>/<test_name>/<inv>__tx<N>.regs` (anchor-v2-testing's
1511///     `ANCHOR_PROFILE_DIR`, used by `anchor debugger`)
1512fn collect_pcs_from_traces(trace_dir: &Path) -> Result<BTreeMap<String, BTreeSet<u64>>> {
1513    let mut result: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
1514
1515    if !trace_dir.exists() {
1516        return Ok(result);
1517    }
1518
1519    visit_dir(trace_dir, &mut result)?;
1520    Ok(result)
1521}
1522
1523fn visit_dir(dir: &Path, result: &mut BTreeMap<String, BTreeSet<u64>>) -> Result<()> {
1524    for entry in fs::read_dir(dir)? {
1525        let entry = entry?;
1526        let path = entry.path();
1527
1528        if path.is_dir() {
1529            visit_dir(&path, result)?;
1530            continue;
1531        }
1532
1533        if path.extension().and_then(|e| e.to_str()) != Some("regs") {
1534            continue;
1535        }
1536
1537        let pid_path = path.with_extension("program_id");
1538        let program_id = match fs::read_to_string(&pid_path) {
1539            Ok(s) => s.trim().to_string(),
1540            Err(_) => continue,
1541        };
1542
1543        let data = fs::read(&path)?;
1544        if data.len() % REGS_ENTRY_SIZE != 0 {
1545            eprintln!(
1546                "warning: {} has unexpected size (not multiple of {})",
1547                path.display(),
1548                REGS_ENTRY_SIZE
1549            );
1550            continue;
1551        }
1552
1553        let pcs = result.entry(program_id).or_default();
1554        let num_steps = data.len() / REGS_ENTRY_SIZE;
1555        for i in 0..num_steps {
1556            let offset = i * REGS_ENTRY_SIZE + 11 * 8;
1557            let pc = u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap());
1558            pcs.insert(pc);
1559        }
1560    }
1561    Ok(())
1562}