1use std::collections::BTreeSet;
9
10use ra_ap_syntax::{
11 AstNode, Edition, SourceFile, SyntaxKind, TextRange,
12 ast::{self, BinaryOp, HasAttrs, HasName, LogicOp},
13};
14use serde_json::json;
15use sha2::{Digest, Sha256};
16
17use crate::{
18 coverage_analysis::PointKind,
19 coverage_report::{
20 BranchAlternativeMeta, BranchMeta, CoverageManifest, DecisionMeta, PointMeta,
21 },
22};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum RustInstrumenterError {
26 SourceTooLarge,
27 Parse(Vec<String>),
28 InvalidRange,
29 InvalidRuntimePath,
30}
31
32impl std::fmt::Display for RustInstrumenterError {
33 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::SourceTooLarge => write!(formatter, "Rust source exceeds the parser range"),
36 Self::Parse(errors) => write!(formatter, "Rust parse failed: {}", errors.join("; ")),
37 Self::InvalidRange => write!(formatter, "Rust parser returned an invalid range"),
38 Self::InvalidRuntimePath => write!(formatter, "invalid generated Rust runtime path"),
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct RustInstrumentedSource {
45 pub code: String,
46 pub manifest: CoverageManifest,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum InsertionKind {
51 End,
52 Direct,
53 Start,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57struct Insertion {
58 offset: usize,
59 kind: InsertionKind,
60 scope_len: usize,
61 rank: usize,
62 text: String,
63}
64
65fn valid_runtime_path(path: &str) -> bool {
66 let mut parts = path.split("::");
67 if !matches!(parts.next(), Some("crate")) {
68 return false;
69 }
70 let parts = parts.collect::<Vec<_>>();
71 !parts.is_empty()
72 && parts.into_iter().all(|part| {
73 !part.is_empty()
74 && part.bytes().enumerate().all(|(index, byte)| {
75 byte == b'_'
76 || byte.is_ascii_alphabetic()
77 || (index > 0 && byte.is_ascii_digit())
78 })
79 })
80}
81
82fn source_has_doctest_fences(source: &str) -> bool {
100 source.lines().any(|line| {
101 let line = line.trim_start();
102 let documentation = line
103 .strip_prefix("///")
104 .or_else(|| line.strip_prefix("//!"))
105 .or_else(|| line.strip_prefix("#![doc"))
106 .or_else(|| line.strip_prefix("#[doc"));
107 documentation.is_some_and(|text| text.contains("```"))
108 })
109}
110
111fn in_const_context(node: &ra_ap_syntax::SyntaxNode) -> bool {
112 let start = node.text_range().start();
113 node.ancestors().any(|ancestor| {
114 ast::Fn::cast(ancestor.clone()).is_some_and(|function| function.const_token().is_some())
115 || ast::BlockExpr::cast(ancestor.clone())
116 .is_some_and(|block| block.const_token().is_some())
117 || ast::Const::can_cast(ancestor.kind())
118 || ast::Static::can_cast(ancestor.kind())
119 || ast::ConstArg::can_cast(ancestor.kind())
120 || ast::ArrayExpr::cast(ancestor).is_some_and(|array| {
121 array
122 .semicolon_token()
123 .is_some_and(|semicolon| start >= semicolon.text_range().end())
124 })
125 })
126}
127
128fn in_global_allocator(node: &ra_ap_syntax::SyntaxNode) -> bool {
143 node.ancestors().any(|ancestor| {
144 ast::Impl::cast(ancestor).is_some_and(|block| {
145 block.trait_().is_some_and(|implemented| {
146 implemented
147 .syntax()
148 .descendants_with_tokens()
149 .filter_map(|element| element.into_token())
150 .any(|token| token.kind() == SyntaxKind::IDENT && token.text() == "GlobalAlloc")
151 })
152 })
153 })
154}
155
156fn cannot_carry_probe(node: &ra_ap_syntax::SyntaxNode) -> bool {
158 in_const_context(node) || in_global_allocator(node)
159}
160
161fn range_offsets(range: TextRange) -> (usize, usize) {
162 (usize::from(range.start()), usize::from(range.end()))
163}
164
165fn push_wrapper(
166 insertions: &mut Vec<Insertion>,
167 range: TextRange,
168 scope: TextRange,
169 rank: usize,
170 prefix: String,
171 suffix: String,
172) {
173 let (start, end) = range_offsets(range);
174 let (scope_start, scope_end) = range_offsets(scope);
175 let scope_len = scope_end - scope_start;
176 insertions.push(Insertion {
177 offset: start,
178 kind: InsertionKind::Start,
179 scope_len,
180 rank,
181 text: prefix,
182 });
183 insertions.push(Insertion {
184 offset: end,
185 kind: InsertionKind::End,
186 scope_len,
187 rank,
188 text: suffix,
189 });
190}
191
192fn push_direct(insertions: &mut Vec<Insertion>, offset: usize, text: String) {
193 insertions.push(Insertion {
194 offset,
195 kind: InsertionKind::Direct,
196 scope_len: 0,
197 rank: 0,
198 text,
199 });
200}
201
202fn apply_insertions(
203 source: &str,
204 mut insertions: Vec<Insertion>,
205) -> Result<String, RustInstrumenterError> {
206 if insertions
207 .iter()
208 .any(|edit| edit.offset > source.len() || !source.is_char_boundary(edit.offset))
209 {
210 return Err(RustInstrumenterError::InvalidRange);
211 }
212 insertions.sort_by(|left, right| {
213 left.offset.cmp(&right.offset).then_with(|| {
214 let kind_order = |kind: InsertionKind| match kind {
215 InsertionKind::End => 0,
216 InsertionKind::Direct => 1,
217 InsertionKind::Start => 2,
218 };
219 kind_order(left.kind)
220 .cmp(&kind_order(right.kind))
221 .then_with(|| match left.kind {
222 InsertionKind::End => left
223 .scope_len
224 .cmp(&right.scope_len)
225 .then_with(|| right.rank.cmp(&left.rank)),
226 InsertionKind::Direct => std::cmp::Ordering::Equal,
227 InsertionKind::Start => right
228 .scope_len
229 .cmp(&left.scope_len)
230 .then_with(|| left.rank.cmp(&right.rank)),
231 })
232 })
233 });
234
235 let mut output = source.to_owned();
236 let mut index = insertions.len();
237 while index > 0 {
238 let offset = insertions[index - 1].offset;
239 let start = insertions[..index].partition_point(|insertion| insertion.offset < offset);
240 let text = insertions[start..index]
241 .iter()
242 .map(|insertion| insertion.text.as_str())
243 .collect::<String>();
244 output.insert_str(offset, &text);
245 index = start;
246 }
247 Ok(output)
248}
249
250fn add_manifest_limitation(manifest: &mut CoverageManifest, file: &str, id: &str, reason: &str) {
251 if manifest
252 .limitations
253 .iter()
254 .any(|limitation| limitation.get("id").and_then(|value| value.as_str()) == Some(id))
255 {
256 return;
257 }
258 manifest.limitations.push(json!({
259 "id": id,
260 "kind": "rust-frontend-readiness",
261 "file": file,
262 "line": 1,
263 "column": 0,
264 "source": "",
265 "reason": reason
266 }));
267}
268
269fn allocate_frame_name(
270 file: &str,
271 condition: &ast::Expr,
272 kind: &str,
273 identifiers: &mut BTreeSet<String>,
274) -> String {
275 let id = stable_id(file, "decision", condition.syntax().text_range(), kind);
276 let suffix = id.rsplit(':').next().unwrap_or("decision");
277 let base = format!("__supercov_decision_{suffix}");
278 let mut candidate = base.clone();
279 let mut attempt = 0_usize;
280 while !identifiers.insert(candidate.clone()) {
281 attempt += 1;
282 candidate = format!("{base}_{attempt}");
283 }
284 candidate
285}
286
287impl std::error::Error for RustInstrumenterError {}
288
289struct SourceLocations<'a> {
290 source: &'a str,
291 line_starts: Vec<usize>,
292}
293
294impl<'a> SourceLocations<'a> {
295 fn new(source: &'a str) -> Self {
296 let mut line_starts = vec![0];
297 line_starts.extend(
298 source
299 .bytes()
300 .enumerate()
301 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
302 );
303 Self {
304 source,
305 line_starts,
306 }
307 }
308
309 fn range(&self, range: TextRange) -> Result<(usize, usize), RustInstrumenterError> {
310 let start = usize::from(range.start());
311 let end = usize::from(range.end());
312 if start > end
313 || end > self.source.len()
314 || !self.source.is_char_boundary(start)
315 || !self.source.is_char_boundary(end)
316 {
317 return Err(RustInstrumenterError::InvalidRange);
318 }
319 Ok((start, end))
320 }
321
322 fn line_column(&self, offset: usize) -> (usize, usize) {
323 let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
324 (line_index + 1, offset - self.line_starts[line_index])
325 }
326
327 fn text(&self, range: TextRange) -> Result<String, RustInstrumenterError> {
328 let (start, end) = self.range(range)?;
329 Ok(self.source[start..end].trim().to_owned())
330 }
331}
332
333fn stable_id(file: &str, kind: &str, range: TextRange, suffix: &str) -> String {
334 let mut hash = Sha256::new();
335 let start = usize::from(range.start()).to_string();
336 let end = usize::from(range.end()).to_string();
337 for value in [file, kind, &start, &end, suffix] {
338 hash.update(value.as_bytes());
339 hash.update([0]);
340 }
341 let digest = hash.finalize();
342 let mut encoded = String::with_capacity(24);
343 for byte in &digest[..12] {
344 use std::fmt::Write as _;
345 write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
346 }
347 format!("rs:{kind}:{encoded}")
348}
349
350struct RustObligationCollector<'a> {
351 file: &'a str,
352 locations: SourceLocations<'a>,
353 manifest: CoverageManifest,
354 point_ids: BTreeSet<String>,
355 decision_ids: BTreeSet<String>,
356 branch_ids: BTreeSet<String>,
357 limitation_ids: BTreeSet<&'static str>,
358 error: Option<RustInstrumenterError>,
359}
360
361impl<'a> RustObligationCollector<'a> {
362 fn new(file: &'a str, source: &'a str) -> Self {
363 Self {
364 file,
365 locations: SourceLocations::new(source),
366 manifest: CoverageManifest {
367 unmeasured: Vec::new(),
368 decisions: Vec::new(),
369 points: Vec::new(),
370 branches: Vec::new(),
371 limitations: Vec::new(),
372 scope: None,
373 },
374 point_ids: BTreeSet::new(),
375 decision_ids: BTreeSet::new(),
376 branch_ids: BTreeSet::new(),
377 limitation_ids: BTreeSet::new(),
378 error: None,
379 }
380 }
381
382 fn location_source(&mut self, range: TextRange) -> Option<(usize, usize, String)> {
383 let result = self.locations.range(range).map(|(start, _)| {
384 let (line, column) = self.locations.line_column(start);
385 (line, column, self.locations.text(range))
386 });
387 match result {
388 Ok((line, column, Ok(source))) => Some((line, column, source)),
389 Ok((_, _, Err(error))) | Err(error) => {
390 self.error.get_or_insert(error);
391 None
392 }
393 }
394 }
395
396 fn point(&mut self, range: TextRange, kind: PointKind, label: Option<String>) {
397 let kind_name = match kind {
398 PointKind::Statement => "statement",
399 PointKind::Function => "function",
400 };
401 let id = stable_id(self.file, kind_name, range, label.as_deref().unwrap_or(""));
402 if !self.point_ids.insert(id.clone()) {
403 return;
404 }
405 let Some((line, column, source)) = self.location_source(range) else {
406 return;
407 };
408 self.manifest.points.push(PointMeta {
409 id,
410 kind,
411 file: self.file.into(),
412 line,
413 column,
414 source,
415 label,
416 });
417 }
418
419 fn atomic_condition_ranges(expression: &ast::Expr, ranges: &mut Vec<TextRange>) {
420 match expression {
421 ast::Expr::ParenExpr(paren) => {
422 if let Some(inner) = paren.expr() {
423 Self::atomic_condition_ranges(&inner, ranges);
424 } else {
425 ranges.push(expression.syntax().text_range());
426 }
427 }
428 ast::Expr::BinExpr(binary)
429 if matches!(
430 binary.op_kind(),
431 Some(BinaryOp::LogicOp(LogicOp::And | LogicOp::Or))
432 ) =>
433 {
434 if let Some(left) = binary.lhs() {
435 Self::atomic_condition_ranges(&left, ranges);
436 }
437 if let Some(right) = binary.rhs() {
438 Self::atomic_condition_ranges(&right, ranges);
439 }
440 }
441 _ => ranges.push(expression.syntax().text_range()),
442 }
443 }
444
445 fn decision(&mut self, test: &ast::Expr, kind: &str) {
446 let range = test.syntax().text_range();
447 let id = stable_id(self.file, "decision", range, kind);
448 if !self.decision_ids.insert(id.clone()) {
449 return;
450 }
451 let Some((line, column, source)) = self.location_source(range) else {
452 return;
453 };
454 let mut condition_ranges = Vec::new();
455 Self::atomic_condition_ranges(test, &mut condition_ranges);
456 let mut conditions = Vec::with_capacity(condition_ranges.len());
457 for condition in condition_ranges {
458 match self.locations.text(condition) {
459 Ok(source) => conditions.push(source),
460 Err(error) => {
461 self.error.get_or_insert(error);
462 return;
463 }
464 }
465 }
466 self.manifest.decisions.push(DecisionMeta {
467 id: id.clone(),
468 file: self.file.into(),
469 line,
470 column,
471 source: source.clone(),
472 conditions,
473 kind: kind.into(),
474 });
475 self.branch_with_id(
476 format!("{id}:outcome"),
477 range,
478 kind,
479 source,
480 [("true", "true"), ("false", "false")],
481 );
482 }
483
484 fn branch<const N: usize>(
485 &mut self,
486 range: TextRange,
487 kind: &str,
488 alternatives: [(&str, &str); N],
489 ) {
490 let id = stable_id(self.file, "branch", range, kind);
491 let Some((_, _, source)) = self.location_source(range) else {
492 return;
493 };
494 self.branch_with_id(id, range, kind, source, alternatives);
495 }
496
497 fn branch_with_id<const N: usize>(
498 &mut self,
499 id: String,
500 range: TextRange,
501 kind: &str,
502 source: String,
503 alternatives: [(&str, &str); N],
504 ) {
505 if !self.branch_ids.insert(id.clone()) {
506 return;
507 }
508 let Some((line, column, _)) = self.location_source(range) else {
509 return;
510 };
511 self.manifest.branches.push(BranchMeta {
512 id: id.clone(),
513 kind: kind.into(),
514 file: self.file.into(),
515 line,
516 column,
517 source,
518 alternatives: alternatives
519 .into_iter()
520 .map(|(suffix, label)| BranchAlternativeMeta {
521 id: format!("{id}:{suffix}"),
522 label: label.into(),
523 })
524 .collect(),
525 });
526 }
527
528 fn limitation(&mut self, id: &'static str, reason: &'static str) {
529 if !self.limitation_ids.insert(id) {
530 return;
531 }
532 self.manifest.limitations.push(json!({
533 "id": id,
534 "kind": "rust-frontend-readiness",
535 "file": self.file,
536 "line": 1,
537 "column": 0,
538 "source": "",
539 "reason": reason
540 }));
541 }
542
543 fn collect(mut self, file: &SourceFile) -> Result<CoverageManifest, RustInstrumenterError> {
544 let root = file.syntax();
545
546 for list in root.descendants().filter_map(ast::StmtList::cast) {
547 for statement in list.statements() {
548 match statement {
549 ast::Stmt::ExprStmt(statement) => {
550 self.point(statement.syntax().text_range(), PointKind::Statement, None);
551 }
552 ast::Stmt::LetStmt(statement) => {
553 self.point(statement.syntax().text_range(), PointKind::Statement, None);
554 }
555 ast::Stmt::Item(_) => {}
556 }
557 }
558 if let Some(tail) = list.tail_expr() {
559 self.point(tail.syntax().text_range(), PointKind::Statement, None);
560 }
561 }
562
563 for function in root.descendants().filter_map(ast::Fn::cast) {
564 if function.body().is_none() {
565 continue;
566 }
567 if function.const_token().is_some() {
568 self.limitation(
569 "rust-const-context-not-instrumented",
570 "Runtime probes cannot execute in const fn or compile-time evaluation",
571 );
572 continue;
573 }
574 let label = function.name().map(|name| name.text().to_string());
575 self.point(function.syntax().text_range(), PointKind::Function, label);
576 }
577
578 for closure in root.descendants().filter_map(ast::ClosureExpr::cast) {
579 self.point(
580 closure.syntax().text_range(),
581 PointKind::Function,
582 Some("<closure>".into()),
583 );
584 }
585
586 for expression in root.descendants().filter_map(ast::IfExpr::cast) {
587 if let Some(condition) = expression.condition() {
588 self.decision(&condition, "if");
589 }
590 }
591 for expression in root.descendants().filter_map(ast::WhileExpr::cast) {
592 if let Some(condition) = expression.condition() {
593 self.decision(&condition, "while");
594 }
595 self.branch(
596 expression.syntax().text_range(),
597 "while-loop",
598 [("zero", "zero iterations"), ("entered", "entered")],
599 );
600 }
601 for guard in root.descendants().filter_map(ast::MatchGuard::cast) {
602 if let Some(condition) = guard.condition() {
603 self.decision(&condition, "match-guard");
604 }
605 }
606
607 for binary in root.descendants().filter_map(ast::BinExpr::cast) {
608 let kind = match binary.op_kind() {
609 Some(BinaryOp::LogicOp(LogicOp::And)) => "logical-and",
610 Some(BinaryOp::LogicOp(LogicOp::Or)) => "logical-or",
611 _ => continue,
612 };
613 let range = binary.rhs().map_or_else(
614 || binary.syntax().text_range(),
615 |right| right.syntax().text_range(),
616 );
617 self.branch(
618 range,
619 kind,
620 [
621 ("short-circuit", "short-circuited"),
622 ("evaluated", "right operand evaluated"),
623 ],
624 );
625 }
626
627 for expression in root.descendants().filter_map(ast::ForExpr::cast) {
628 self.branch(
629 expression.syntax().text_range(),
630 "for-loop",
631 [("zero", "zero iterations"), ("entered", "entered")],
632 );
633 }
634 for arm in root.descendants().filter_map(ast::MatchArm::cast) {
635 self.branch(
636 arm.syntax().text_range(),
637 "match-arm",
638 [("missed", "not selected"), ("selected", "selected")],
639 );
640 }
641 for expression in root.descendants().filter_map(ast::TryExpr::cast) {
642 self.branch(
643 expression.syntax().text_range(),
644 "try-operator",
645 [("continued", "continued"), ("returned", "early return")],
646 );
647 }
648
649 if root.descendants().any(|node| {
650 ast::MacroCall::can_cast(node.kind()) || ast::MacroExpr::can_cast(node.kind())
651 }) {
652 self.limitation(
653 "rust-macro-expansion-not-instrumented",
654 "Declarative and procedural macro expansions are not yet part of the owned source denominator",
655 );
656 }
657
658 if source_has_doctest_fences(&root.text().to_string()) {
667 self.limitation(
668 "rust-doctests-not-measured",
669 "Doctests are compiled by rustdoc as separate crates and are not yet measured",
670 );
671 }
672 let bears_obligation = |node: &ra_ap_syntax::SyntaxNode| {
677 ast::StmtList::cast(node.clone()).is_some_and(|list| {
678 list.statements().next().is_some() || list.tail_expr().is_some()
679 }) || ast::IfExpr::can_cast(node.kind())
680 || ast::WhileExpr::can_cast(node.kind())
681 || ast::MatchGuard::can_cast(node.kind())
682 || ast::ForExpr::can_cast(node.kind())
683 || ast::MatchArm::can_cast(node.kind())
684 || ast::TryExpr::can_cast(node.kind())
685 || ast::ClosureExpr::can_cast(node.kind())
686 || ast::BinExpr::cast(node.clone()).is_some_and(|binary| {
687 matches!(
688 binary.op_kind(),
689 Some(BinaryOp::LogicOp(LogicOp::And | LogicOp::Or))
690 )
691 })
692 };
693 if root
694 .descendants()
695 .any(|node| bears_obligation(&node) && in_const_context(&node))
696 {
697 self.limitation(
698 "rust-const-context-not-instrumented",
699 "Runtime probes cannot execute in const fn or compile-time evaluation",
700 );
701 }
702 if root
703 .descendants()
704 .any(|node| bears_obligation(&node) && in_global_allocator(&node))
705 {
706 self.limitation(
707 "rust-global-allocator-not-instrumented",
708 "Probing a GlobalAlloc implementation recurses into itself, because the runtime allocates",
709 );
710 }
711
712 if let Some(error) = self.error {
713 return Err(error);
714 }
715 self.manifest
716 .decisions
717 .sort_by(|left, right| left.id.cmp(&right.id));
718 self.manifest
719 .points
720 .sort_by(|left, right| left.id.cmp(&right.id));
721 self.manifest
722 .branches
723 .sort_by(|left, right| left.id.cmp(&right.id));
724 self.manifest.limitations.sort_by(|left, right| {
725 left.get("id")
726 .and_then(|value| value.as_str())
727 .cmp(&right.get("id").and_then(|value| value.as_str()))
728 });
729 Ok(self.manifest)
730 }
731}
732
733pub fn build_rust_manifest(
734 file: &str,
735 source: &str,
736) -> Result<CoverageManifest, RustInstrumenterError> {
737 if source.len() > u32::MAX as usize {
738 return Err(RustInstrumenterError::SourceTooLarge);
739 }
740 let parsed = SourceFile::parse(source, Edition::CURRENT);
741 let errors = parsed
742 .errors()
743 .into_iter()
744 .map(|error| error.to_string())
745 .collect::<Vec<_>>();
746 if !errors.is_empty() {
747 return Err(RustInstrumenterError::Parse(errors));
748 }
749 RustObligationCollector::new(file, source).collect(&parsed.tree())
750}
751
752fn block_entry_offset(block: &ast::BlockExpr) -> Option<usize> {
753 let list = block.stmt_list()?;
754 list.attrs()
755 .last()
756 .map(|attribute| usize::from(attribute.syntax().text_range().end()))
757 .or_else(|| {
758 list.l_curly_token()
759 .map(|token| usize::from(token.text_range().end()))
760 })
761}
762
763fn instrument_decision(
764 insertions: &mut Vec<Insertion>,
765 runtime_path: &str,
766 file: &str,
767 condition: &ast::Expr,
768 kind: &str,
769 frame_name: &str,
770) -> bool {
771 if cannot_carry_probe(condition.syntax())
772 || condition
773 .syntax()
774 .descendants()
775 .any(|node| ast::LetExpr::can_cast(node.kind()))
776 {
777 return false;
778 }
779 let range = condition.syntax().text_range();
780 let id = stable_id(file, "decision", range, kind);
781 let mut condition_ranges = Vec::new();
782 RustObligationCollector::atomic_condition_ranges(condition, &mut condition_ranges);
783 push_wrapper(
784 insertions,
785 range,
786 range,
787 0,
788 format!(
789 "({{ let mut {frame_name} = {runtime_path}::DecisionFrame::new({id:?}, {}); {runtime_path}::decision((",
790 condition_ranges.len()
791 ),
792 format!("), &mut {frame_name}) }})"),
793 );
794 for (index, atomic_range) in condition_ranges.into_iter().enumerate() {
795 push_wrapper(
796 insertions,
797 atomic_range,
798 range,
799 1,
800 format!("{runtime_path}::condition(("),
801 format!("), &mut {frame_name}, {index})"),
802 );
803 }
804 true
805}
806
807pub fn instrument_rust_source(
814 file: &str,
815 source: &str,
816 runtime_path: &str,
817) -> Result<RustInstrumentedSource, RustInstrumenterError> {
818 if !valid_runtime_path(runtime_path) {
819 return Err(RustInstrumenterError::InvalidRuntimePath);
820 }
821 let mut manifest = build_rust_manifest(file, source)?;
822 let parsed = SourceFile::parse(source, Edition::CURRENT);
823 let tree = parsed.tree();
824 let root = tree.syntax();
825 let mut insertions = Vec::new();
826 let mut identifiers = root
827 .descendants_with_tokens()
828 .filter_map(|element| element.into_token())
829 .filter(|token| token.kind() == SyntaxKind::IDENT)
830 .map(|token| token.text().to_string())
831 .collect::<BTreeSet<_>>();
832
833 let mut skipped_attributed_statement = false;
834 let attributed_probe = |insertions: &mut Vec<Insertion>,
844 skipped: &mut bool,
845 expression: Option<ast::Expr>,
846 has_attrs: bool,
847 range: TextRange,
848 id: String| {
849 if !has_attrs {
850 push_direct(
851 insertions,
852 usize::from(range.start()),
853 format!("{runtime_path}::hit({id:?});"),
854 );
855 return;
856 }
857 if let Some(ast::Expr::BlockExpr(block)) = expression
858 && let Some(offset) = block_entry_offset(&block)
859 {
860 push_direct(
861 insertions,
862 offset,
863 format!("\n{runtime_path}::hit({id:?});"),
864 );
865 return;
866 }
867 *skipped = true;
868 };
869 for list in root.descendants().filter_map(ast::StmtList::cast) {
870 for statement in list.statements() {
871 let (range, expression, has_attrs) = match statement {
872 ast::Stmt::ExprStmt(statement) if !cannot_carry_probe(statement.syntax()) => {
873 let expression = statement.expr();
874 let has_attrs = expression
877 .as_ref()
878 .is_some_and(|expression| expression.attrs().next().is_some());
879 (statement.syntax().text_range(), expression, has_attrs)
880 }
881 ast::Stmt::LetStmt(statement) if !cannot_carry_probe(statement.syntax()) => {
882 let has_attrs = statement.attrs().next().is_some();
883 (statement.syntax().text_range(), None, has_attrs)
884 }
885 _ => continue,
886 };
887 let id = stable_id(file, "statement", range, "");
888 attributed_probe(
889 &mut insertions,
890 &mut skipped_attributed_statement,
891 expression,
892 has_attrs,
893 range,
894 id,
895 );
896 }
897 if let Some(tail) = list
898 .tail_expr()
899 .filter(|tail| !cannot_carry_probe(tail.syntax()))
900 {
901 let range = tail.syntax().text_range();
902 let id = stable_id(file, "statement", range, "");
903 let has_attrs = tail.attrs().next().is_some();
904 attributed_probe(
905 &mut insertions,
906 &mut skipped_attributed_statement,
907 Some(tail),
908 has_attrs,
909 range,
910 id,
911 );
912 }
913 }
914
915 for function in root.descendants().filter_map(ast::Fn::cast) {
916 if cannot_carry_probe(function.syntax()) {
919 continue;
920 }
921 let Some(body) = function.body() else {
922 continue;
923 };
924 let label = function.name().map(|name| name.text().to_string());
925 let id = stable_id(
926 file,
927 "function",
928 function.syntax().text_range(),
929 label.as_deref().unwrap_or(""),
930 );
931 if let Some(offset) = block_entry_offset(&body) {
932 push_direct(
933 &mut insertions,
934 offset,
935 format!("\n{runtime_path}::hit({id:?});"),
936 );
937 }
938 }
939
940 for closure in root.descendants().filter_map(ast::ClosureExpr::cast) {
941 let Some(body) = closure.body() else {
942 continue;
943 };
944 if cannot_carry_probe(body.syntax()) {
945 continue;
946 }
947 let id = stable_id(file, "function", closure.syntax().text_range(), "<closure>");
948 if let ast::Expr::BlockExpr(block) = &body {
949 if let Some(offset) = block_entry_offset(block) {
950 push_direct(
951 &mut insertions,
952 offset,
953 format!("\n{runtime_path}::hit({id:?});"),
954 );
955 }
956 } else {
957 let range = body.syntax().text_range();
958 push_wrapper(
959 &mut insertions,
960 range,
961 closure.syntax().text_range(),
962 0,
963 format!("{{ {runtime_path}::hit({id:?}); ("),
964 ") }".into(),
965 );
966 }
967 }
968
969 let mut skipped_let_condition = false;
970 for expression in root.descendants().filter_map(ast::IfExpr::cast) {
971 if let Some(condition) = expression.condition() {
972 let frame_name = allocate_frame_name(file, &condition, "if", &mut identifiers);
973 if !instrument_decision(
974 &mut insertions,
975 runtime_path,
976 file,
977 &condition,
978 "if",
979 &frame_name,
980 ) && condition
981 .syntax()
982 .descendants()
983 .any(|node| ast::LetExpr::can_cast(node.kind()))
984 {
985 skipped_let_condition = true;
986 }
987 }
988 }
989 for expression in root.descendants().filter_map(ast::WhileExpr::cast) {
990 if let Some(condition) = expression.condition() {
991 let frame_name = allocate_frame_name(file, &condition, "while", &mut identifiers);
992 if !instrument_decision(
993 &mut insertions,
994 runtime_path,
995 file,
996 &condition,
997 "while",
998 &frame_name,
999 ) && condition
1000 .syntax()
1001 .descendants()
1002 .any(|node| ast::LetExpr::can_cast(node.kind()))
1003 {
1004 skipped_let_condition = true;
1005 }
1006 }
1007 }
1008 for guard in root.descendants().filter_map(ast::MatchGuard::cast) {
1009 if let Some(condition) = guard.condition() {
1010 let frame_name = allocate_frame_name(file, &condition, "match-guard", &mut identifiers);
1011 instrument_decision(
1012 &mut insertions,
1013 runtime_path,
1014 file,
1015 &condition,
1016 "match-guard",
1017 &frame_name,
1018 );
1019 }
1020 }
1021
1022 if skipped_attributed_statement {
1023 add_manifest_limitation(
1024 &mut manifest,
1025 file,
1026 "rust-attributed-statement-probes-not-injected",
1027 "Statements carrying outer attributes cannot take an adjacent probe without changing cfg selection",
1028 );
1029 }
1030 if skipped_let_condition {
1031 add_manifest_limitation(
1032 &mut manifest,
1033 file,
1034 "rust-let-chain-probes-not-injected",
1035 "Pattern conditions and let chains remain in the denominator but do not yet have semantics-proven owned condition probes",
1036 );
1037 }
1038 if manifest.branches.iter().any(|branch| {
1039 !branch.id.ends_with(":outcome")
1040 && matches!(
1041 branch.kind.as_str(),
1042 "logical-and"
1043 | "logical-or"
1044 | "for-loop"
1045 | "while-loop"
1046 | "match-arm"
1047 | "try-operator"
1048 )
1049 }) {
1050 add_manifest_limitation(
1051 &mut manifest,
1052 file,
1053 "rust-structural-branch-probes-not-yet-injected",
1054 "Logical selection, loop, match-arm and try-operator obligations remain visible but their owned observations are not yet injected",
1055 );
1056 }
1057 manifest.limitations.sort_by(|left, right| {
1058 left.get("id")
1059 .and_then(|value| value.as_str())
1060 .cmp(&right.get("id").and_then(|value| value.as_str()))
1061 });
1062
1063 let code = apply_insertions(source, insertions)?;
1064 let transformed = SourceFile::parse(&code, Edition::CURRENT);
1065 let errors = transformed
1066 .errors()
1067 .into_iter()
1068 .map(|error| error.to_string())
1069 .collect::<Vec<_>>();
1070 if !errors.is_empty() {
1071 return Err(RustInstrumenterError::Parse(errors));
1072 }
1073 Ok(RustInstrumentedSource { code, manifest })
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use std::{
1079 fs,
1080 process::Command,
1081 time::{SystemTime, UNIX_EPOCH},
1082 };
1083
1084 use super::*;
1085
1086 const NOOP_RUNTIME: &str = r#"
1087#[doc(hidden)]
1088mod __supercov_runtime_v1 {
1089 pub struct DecisionFrame;
1090 impl DecisionFrame {
1091 pub fn new(_: &'static str, _: usize) -> Self { Self }
1092 }
1093 pub fn hit(_: &'static str) {}
1094 pub fn condition(value: bool, _: &mut DecisionFrame, _: usize) -> bool { value }
1095 pub fn decision(value: bool, _: &mut DecisionFrame) -> bool { value }
1096}
1097"#;
1098
1099 fn compile_and_run(source: &str, name: &str) -> std::process::Output {
1100 let nonce = SystemTime::now()
1101 .duration_since(UNIX_EPOCH)
1102 .unwrap()
1103 .as_nanos();
1104 let directory = std::env::temp_dir().join(format!(
1105 "supercov-rust-transform-{}-{nonce}-{name}",
1106 std::process::id()
1107 ));
1108 fs::create_dir(&directory).unwrap();
1109 let input = directory.join("main.rs");
1110 let binary = directory.join("program");
1111 fs::write(&input, source).unwrap();
1112 let compile = Command::new("rustc")
1113 .arg("--edition=2024")
1114 .arg(&input)
1115 .arg("-o")
1116 .arg(&binary)
1117 .output()
1118 .unwrap();
1119 assert!(
1120 compile.status.success(),
1121 "rustc failed:\n{}\nsource:\n{source}",
1122 String::from_utf8_lossy(&compile.stderr)
1123 );
1124 let output = Command::new(&binary).output().unwrap();
1125 fs::remove_dir_all(directory).unwrap();
1126 output
1127 }
1128
1129 #[test]
1130 fn discovers_rust_obligations_with_exact_ranges_and_stable_ids() {
1131 let source = r#"fn classify<T>(values: &[T], first: bool, second: bool, third: bool) -> Option<&T> {
1132 let picked = if first && (second || third) {
1133 values.first()?
1134 } else {
1135 None
1136 };
1137 for value in values {
1138 if first || second {
1139 return Some(value);
1140 }
1141 }
1142 match picked {
1143 Some(value) if second && third => Some(value),
1144 _ => None,
1145 }
1146}
1147
1148fn closure(value: i32) -> bool {
1149 (|candidate| candidate > 0)(value)
1150}
1151"#;
1152 let first = build_rust_manifest("src/lib.rs", source).unwrap();
1153 let second = build_rust_manifest("src/lib.rs", source).unwrap();
1154 assert_eq!(first, second);
1155 assert!(first.points.iter().any(|point| {
1156 point.kind == PointKind::Function && point.label.as_deref() == Some("classify")
1157 }));
1158 assert!(first.points.iter().any(|point| {
1159 point.kind == PointKind::Function && point.label.as_deref() == Some("<closure>")
1160 }));
1161 let first_if = first
1162 .decisions
1163 .iter()
1164 .find(|decision| decision.line == 2)
1165 .unwrap();
1166 assert_eq!(first_if.conditions, ["first", "second", "third"]);
1167 assert_eq!(first_if.column, 20);
1168 assert!(
1169 first
1170 .branches
1171 .iter()
1172 .any(|branch| branch.kind == "for-loop")
1173 );
1174 assert!(
1175 first
1176 .branches
1177 .iter()
1178 .any(|branch| branch.kind == "match-arm")
1179 );
1180 assert!(
1181 first
1182 .branches
1183 .iter()
1184 .any(|branch| branch.kind == "try-operator")
1185 );
1186 assert!(first.decisions.iter().all(|decision| {
1187 decision.id.starts_with("rs:decision:") && decision.conditions.len() >= 2
1188 }));
1189 assert!(first.limitations.is_empty());
1190 }
1191
1192 #[test]
1193 fn declares_macro_and_const_boundaries_instead_of_hiding_them() {
1194 let source = r#"const fn doubled(value: usize) -> usize { value * 2 }
1195
1196fn checked(value: bool) -> bool {
1197 assert!(value);
1198 const { doubled(2) == 4 }
1199}
1200"#;
1201 let manifest = build_rust_manifest("src/lib.rs", source).unwrap();
1202 let ids = manifest
1203 .limitations
1204 .iter()
1205 .filter_map(|limitation| limitation.get("id")?.as_str())
1206 .collect::<BTreeSet<_>>();
1207 assert_eq!(
1208 ids,
1209 BTreeSet::from([
1210 "rust-const-context-not-instrumented",
1211 "rust-macro-expansion-not-instrumented"
1212 ])
1213 );
1214 assert!(!manifest.points.iter().any(|point| {
1215 point.kind == PointKind::Function && point.label.as_deref() == Some("doubled")
1216 }));
1217 }
1218
1219 #[test]
1220 fn transforms_points_and_nested_decisions_without_changing_behavior() {
1221 let source = r#"use std::sync::atomic::{AtomicUsize, Ordering};
1222
1223static CALLS: AtomicUsize = AtomicUsize::new(0);
1224
1225fn observed(name: &str, value: bool) -> bool {
1226 let order = CALLS.fetch_add(1, Ordering::SeqCst);
1227 println!("{order}:{name}:{value}");
1228 value
1229}
1230
1231fn classify(first: bool, second: bool, third: bool) -> i32 {
1232 if observed("a", first) && (observed("b", second) || observed("c", third)) {
1233 7
1234 } else {
1235 3
1236 }
1237}
1238
1239fn main() {
1240 let closure = |value: i32| value + 1;
1241 println!("result={}", closure(classify(true, false, true)));
1242}
1243"#;
1244 let transformed =
1245 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1246 assert!(transformed.code.contains("::condition("));
1247 assert!(transformed.code.contains("::decision("));
1248 assert!(transformed.code.contains("::hit("));
1249 let original = compile_and_run(source, "original");
1250 let instrumented = compile_and_run(
1251 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1252 "instrumented",
1253 );
1254 assert_eq!(instrumented.status, original.status);
1255 assert_eq!(instrumented.stdout, original.stdout);
1256 assert_eq!(instrumented.stderr, original.stderr);
1257 }
1258
1259 #[test]
1260 fn skips_let_chains_and_const_contexts_with_explicit_limitations() {
1261 let source = r#"const fn enabled(value: bool) -> bool {
1262 if value { true } else { false }
1263}
1264
1265fn classify(value: Option<bool>, fallback: bool) -> bool {
1266 if let Some(inner) = value && inner && fallback { true } else { false }
1267}
1268"#;
1269 let transformed =
1270 instrument_rust_source("src/lib.rs", source, "crate::__supercov_runtime_v1").unwrap();
1271 let ids = transformed
1272 .manifest
1273 .limitations
1274 .iter()
1275 .filter_map(|limitation| limitation.get("id")?.as_str())
1276 .collect::<BTreeSet<_>>();
1277 assert!(ids.contains("rust-const-context-not-instrumented"));
1278 assert!(ids.contains("rust-let-chain-probes-not-injected"));
1279 assert!(!transformed.code.contains("condition("));
1280 }
1281
1282 #[test]
1283 fn instrumented_const_and_static_initialisers_still_compile() {
1284 let source = r#"const DIRECT: usize = if cfg!(unix) { 100 } else { 1_000 };
1290static WIDTH: usize = if cfg!(unix) { 2 } else { 4 };
1291
1292enum Mode {
1293 Narrow = if cfg!(unix) { 1 } else { 2 },
1294}
1295
1296struct Buffer([u8; if cfg!(unix) { 4 } else { 8 }]);
1297
1298impl Buffer {
1299 const SPAN: usize = if cfg!(unix) { 5 } else { 9 };
1300}
1301
1302fn scaled(flag: bool) -> usize {
1303 const LOCAL: usize = if cfg!(unix) { 3 } else { 6 };
1304 if flag { LOCAL + Buffer::SPAN } else { DIRECT + WIDTH }
1305}
1306
1307fn main() {
1308 let buffer = Buffer([0; if cfg!(unix) { 4 } else { 8 }]);
1309 println!(
1310 "{} {} {} {}",
1311 scaled(true),
1312 scaled(false),
1313 Mode::Narrow as usize,
1314 buffer.0.len()
1315 );
1316}
1317"#;
1318 let transformed =
1319 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1320 assert!(transformed.code.contains("::decision("));
1323 let ids = transformed
1324 .manifest
1325 .limitations
1326 .iter()
1327 .filter_map(|limitation| limitation.get("id")?.as_str())
1328 .collect::<BTreeSet<_>>();
1329 assert!(ids.contains("rust-const-context-not-instrumented"));
1330
1331 let original = compile_and_run(source, "const-original");
1332 let instrumented = compile_and_run(
1333 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1334 "const-instrumented",
1335 );
1336 assert_eq!(instrumented.status, original.status);
1337 assert_eq!(instrumented.stdout, original.stdout);
1338 assert_eq!(instrumented.stderr, original.stderr);
1339 }
1340
1341 #[test]
1342 fn a_probed_global_allocator_would_recurse_into_itself() {
1343 let source = r#"use std::alloc::{GlobalAlloc, Layout, System};
1349
1350struct Odd;
1351
1352unsafe impl GlobalAlloc for Odd {
1353 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
1354 if layout.align() == 1 && layout.size() > 0 {
1355 System.alloc(layout)
1356 } else {
1357 System.alloc(layout)
1358 }
1359 }
1360
1361 unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
1362 System.dealloc(pointer, layout);
1363 }
1364}
1365
1366#[global_allocator]
1367static ODD: Odd = Odd;
1368
1369fn classify(flag: bool) -> usize {
1370 if flag { 1 } else { 2 }
1371}
1372
1373fn main() {
1374 let held = std::vec![7u8; 32];
1375 println!("{} {}", classify(!held.is_empty()), held.len());
1376}
1377"#;
1378 let transformed =
1379 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1380 let allocator = transformed
1382 .code
1383 .split("unsafe impl GlobalAlloc for Odd")
1384 .nth(1)
1385 .and_then(|rest| rest.split("#[global_allocator]").next())
1386 .expect("the instrumented source still contains the allocator impl");
1387 assert!(
1388 !allocator.contains("__supercov_runtime_v1"),
1389 "probe injected into a GlobalAlloc impl:\n{allocator}"
1390 );
1391 assert!(transformed.code.contains("::decision("));
1393 let ids = transformed
1394 .manifest
1395 .limitations
1396 .iter()
1397 .filter_map(|limitation| limitation.get("id")?.as_str())
1398 .collect::<BTreeSet<_>>();
1399 assert!(ids.contains("rust-global-allocator-not-instrumented"));
1400
1401 let original = compile_and_run(source, "alloc-original");
1402 let instrumented = compile_and_run(
1403 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1404 "alloc-instrumented",
1405 );
1406 assert_eq!(instrumented.status, original.status);
1407 assert_eq!(instrumented.stdout, original.stdout);
1408 assert_eq!(instrumented.stderr, original.stderr);
1409 }
1410
1411 #[test]
1412 fn doctest_fences_declare_an_unmeasured_surface() {
1413 let with_doctest = r#"/// Doubles a value.
1417///
1418/// ```
1419/// assert_eq!(demo::double(2), 4);
1420/// ```
1421pub fn double(value: usize) -> usize {
1422 value * 2
1423}
1424"#;
1425 let manifest = build_rust_manifest("src/lib.rs", with_doctest).unwrap();
1426 assert!(manifest.limitations.iter().any(|limitation| {
1427 limitation.get("id").and_then(|id| id.as_str()) == Some("rust-doctests-not-measured")
1428 }));
1429
1430 let without_doctest = r#"/// Doubles a value, documented without examples.
1433pub fn double(value: usize) -> usize {
1434 // a stray fence in a plain comment: ```
1435 value * 2
1436}
1437"#;
1438 let manifest = build_rust_manifest("src/lib.rs", without_doctest).unwrap();
1439 assert!(!manifest.limitations.iter().any(|limitation| {
1440 limitation.get("id").and_then(|id| id.as_str()) == Some("rust-doctests-not-measured")
1441 }));
1442 }
1443
1444 #[test]
1445 fn cfg_gated_sibling_blocks_keep_their_tail_position() {
1446 let source = r#"pub fn is_available() -> bool {
1452 #[cfg(target_endian = "little")]
1453 {
1454 true
1455 }
1456 #[cfg(not(target_endian = "little"))]
1457 {
1458 false
1459 }
1460}
1461
1462fn main() {
1463 println!("{}", is_available());
1464}
1465"#;
1466 let transformed =
1467 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1468 let original = compile_and_run(source, "cfg-original");
1469 let instrumented = compile_and_run(
1470 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1471 "cfg-instrumented",
1472 );
1473 assert_eq!(instrumented.status, original.status);
1474 assert_eq!(instrumented.stdout, original.stdout);
1475 assert!(
1477 transformed
1478 .code
1479 .contains("{\n\ncrate::__supercov_runtime_v1::hit(")
1480 || transformed
1481 .code
1482 .contains("{\ncrate::__supercov_runtime_v1::hit(")
1483 );
1484
1485 let attributed_let = r#"fn main() {
1487 #[cfg(target_endian = "little")]
1488 let value = 1;
1489 #[cfg(not(target_endian = "little"))]
1490 let value = 2;
1491 println!("{value}");
1492}
1493"#;
1494 let transformed = instrument_rust_source(
1495 "src/main.rs",
1496 attributed_let,
1497 "crate::__supercov_runtime_v1",
1498 )
1499 .unwrap();
1500 let ids = transformed
1501 .manifest
1502 .limitations
1503 .iter()
1504 .filter_map(|limitation| limitation.get("id")?.as_str())
1505 .collect::<BTreeSet<_>>();
1506 assert!(ids.contains("rust-attributed-statement-probes-not-injected"));
1507 let original = compile_and_run(attributed_let, "cfg-let-original");
1508 let instrumented = compile_and_run(
1509 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1510 "cfg-let-instrumented",
1511 );
1512 assert_eq!(instrumented.stdout, original.stdout);
1513 }
1514
1515 #[test]
1516 fn rejects_non_crate_local_runtime_paths() {
1517 assert_eq!(
1518 instrument_rust_source("src/lib.rs", "fn okay() {}", "supercov::runtime"),
1519 Err(RustInstrumenterError::InvalidRuntimePath)
1520 );
1521 }
1522
1523 #[test]
1524 fn rejects_invalid_rust_without_partial_obligations() {
1525 assert!(matches!(
1526 build_rust_manifest("src/lib.rs", "fn broken( {\n"),
1527 Err(RustInstrumenterError::Parse(_))
1528 ));
1529 }
1530}