1use {
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
23pub 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 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 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 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
136pub 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) = ¤t_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 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
1486fn 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
1505fn 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}