1use std::collections::BTreeSet;
9
10use ra_ap_syntax::{
11 AstNode, Edition, SourceFile, SyntaxKind, TextRange,
12 ast::{self, BinaryOp, HasAttrs, HasLoopBody, 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 in_const_context(node: &ra_ap_syntax::SyntaxNode) -> bool {
100 let start = node.text_range().start();
101 node.ancestors().any(|ancestor| {
102 ast::Fn::cast(ancestor.clone()).is_some_and(|function| function.const_token().is_some())
103 || ast::BlockExpr::cast(ancestor.clone())
104 .is_some_and(|block| block.const_token().is_some())
105 || ast::Const::can_cast(ancestor.kind())
106 || ast::Static::can_cast(ancestor.kind())
107 || ast::ConstArg::can_cast(ancestor.kind())
108 || ast::ArrayExpr::cast(ancestor).is_some_and(|array| {
109 array
110 .semicolon_token()
111 .is_some_and(|semicolon| start >= semicolon.text_range().end())
112 })
113 })
114}
115
116fn in_global_allocator(node: &ra_ap_syntax::SyntaxNode) -> bool {
131 node.ancestors().any(|ancestor| {
132 ast::Impl::cast(ancestor).is_some_and(|block| {
133 block.trait_().is_some_and(|implemented| {
134 implemented
135 .syntax()
136 .descendants_with_tokens()
137 .filter_map(|element| element.into_token())
138 .any(|token| token.kind() == SyntaxKind::IDENT && token.text() == "GlobalAlloc")
139 })
140 })
141 })
142}
143
144fn cannot_carry_probe(node: &ra_ap_syntax::SyntaxNode) -> bool {
146 in_const_context(node) || in_global_allocator(node)
147}
148
149fn range_offsets(range: TextRange) -> (usize, usize) {
150 (usize::from(range.start()), usize::from(range.end()))
151}
152
153fn push_wrapper(
154 insertions: &mut Vec<Insertion>,
155 range: TextRange,
156 scope: TextRange,
157 rank: usize,
158 prefix: String,
159 suffix: String,
160) {
161 let (start, end) = range_offsets(range);
162 let (scope_start, scope_end) = range_offsets(scope);
163 let scope_len = scope_end - scope_start;
164 insertions.push(Insertion {
165 offset: start,
166 kind: InsertionKind::Start,
167 scope_len,
168 rank,
169 text: prefix,
170 });
171 insertions.push(Insertion {
172 offset: end,
173 kind: InsertionKind::End,
174 scope_len,
175 rank,
176 text: suffix,
177 });
178}
179
180fn push_direct(insertions: &mut Vec<Insertion>, offset: usize, text: String) {
181 insertions.push(Insertion {
182 offset,
183 kind: InsertionKind::Direct,
184 scope_len: 0,
185 rank: 0,
186 text,
187 });
188}
189
190fn apply_insertions(
191 source: &str,
192 mut insertions: Vec<Insertion>,
193) -> Result<String, RustInstrumenterError> {
194 if insertions
195 .iter()
196 .any(|edit| edit.offset > source.len() || !source.is_char_boundary(edit.offset))
197 {
198 return Err(RustInstrumenterError::InvalidRange);
199 }
200 insertions.sort_by(|left, right| {
201 left.offset.cmp(&right.offset).then_with(|| {
202 let kind_order = |kind: InsertionKind| match kind {
203 InsertionKind::End => 0,
204 InsertionKind::Direct => 1,
205 InsertionKind::Start => 2,
206 };
207 kind_order(left.kind)
208 .cmp(&kind_order(right.kind))
209 .then_with(|| match left.kind {
210 InsertionKind::End => left
211 .scope_len
212 .cmp(&right.scope_len)
213 .then_with(|| right.rank.cmp(&left.rank)),
214 InsertionKind::Direct => std::cmp::Ordering::Equal,
215 InsertionKind::Start => right
216 .scope_len
217 .cmp(&left.scope_len)
218 .then_with(|| left.rank.cmp(&right.rank)),
219 })
220 })
221 });
222
223 let mut output = source.to_owned();
224 let mut index = insertions.len();
225 while index > 0 {
226 let offset = insertions[index - 1].offset;
227 let start = insertions[..index].partition_point(|insertion| insertion.offset < offset);
228 let text = insertions[start..index]
229 .iter()
230 .map(|insertion| insertion.text.as_str())
231 .collect::<String>();
232 output.insert_str(offset, &text);
233 index = start;
234 }
235 Ok(output)
236}
237
238fn add_manifest_limitation(manifest: &mut CoverageManifest, file: &str, id: &str, reason: &str) {
239 if manifest
240 .limitations
241 .iter()
242 .any(|limitation| limitation.get("id").and_then(|value| value.as_str()) == Some(id))
243 {
244 return;
245 }
246 manifest.limitations.push(json!({
247 "id": id,
248 "kind": "rust-frontend-readiness",
249 "file": file,
250 "line": 1,
251 "column": 0,
252 "source": "",
253 "reason": reason
254 }));
255}
256
257fn allocate_frame_name(
258 file: &str,
259 condition: &ast::Expr,
260 kind: &str,
261 identifiers: &mut BTreeSet<String>,
262) -> String {
263 let id = stable_id(file, "decision", condition.syntax().text_range(), kind);
264 let suffix = id.rsplit(':').next().unwrap_or("decision");
265 let base = format!("__supercov_decision_{suffix}");
266 let mut candidate = base.clone();
267 let mut attempt = 0_usize;
268 while !identifiers.insert(candidate.clone()) {
269 attempt += 1;
270 candidate = format!("{base}_{attempt}");
271 }
272 candidate
273}
274
275fn allocate_table_name(
278 file: &str,
279 expression: &ast::MatchExpr,
280 identifiers: &mut BTreeSet<String>,
281) -> String {
282 let id = stable_id(file, "match", expression.syntax().text_range(), "arms");
283 let suffix = id
284 .rsplit(':')
285 .next()
286 .unwrap_or("match")
287 .to_ascii_uppercase();
288 let base = format!("__SUPERCOV_ARMS_{suffix}");
289 let mut candidate = base.clone();
290 let mut attempt = 0_usize;
291 while !identifiers.insert(candidate.clone()) {
292 attempt += 1;
293 candidate = format!("{base}_{attempt}");
294 }
295 candidate
296}
297
298fn allocate_flag_name(
300 file: &str,
301 expression: &ast::WhileExpr,
302 identifiers: &mut BTreeSet<String>,
303) -> String {
304 let id = stable_id(file, "loop", expression.syntax().text_range(), "flag");
305 let suffix = id.rsplit(':').next().unwrap_or("loop");
306 let base = format!("__supercov_loop_{suffix}");
307 let mut candidate = base.clone();
308 let mut attempt = 0_usize;
309 while !identifiers.insert(candidate.clone()) {
310 attempt += 1;
311 candidate = format!("{base}_{attempt}");
312 }
313 candidate
314}
315
316impl std::error::Error for RustInstrumenterError {}
317
318struct SourceLocations<'a> {
319 source: &'a str,
320 line_starts: Vec<usize>,
321}
322
323impl<'a> SourceLocations<'a> {
324 fn new(source: &'a str) -> Self {
325 let mut line_starts = vec![0];
326 line_starts.extend(
327 source
328 .bytes()
329 .enumerate()
330 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
331 );
332 Self {
333 source,
334 line_starts,
335 }
336 }
337
338 fn range(&self, range: TextRange) -> Result<(usize, usize), RustInstrumenterError> {
339 let start = usize::from(range.start());
340 let end = usize::from(range.end());
341 if start > end
342 || end > self.source.len()
343 || !self.source.is_char_boundary(start)
344 || !self.source.is_char_boundary(end)
345 {
346 return Err(RustInstrumenterError::InvalidRange);
347 }
348 Ok((start, end))
349 }
350
351 fn line_column(&self, offset: usize) -> (usize, usize) {
352 let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
353 (line_index + 1, offset - self.line_starts[line_index])
354 }
355
356 fn text(&self, range: TextRange) -> Result<String, RustInstrumenterError> {
357 let (start, end) = self.range(range)?;
358 Ok(self.source[start..end].trim().to_owned())
359 }
360}
361
362fn stable_id(file: &str, kind: &str, range: TextRange, suffix: &str) -> String {
363 let mut hash = Sha256::new();
364 let start = usize::from(range.start()).to_string();
365 let end = usize::from(range.end()).to_string();
366 for value in [file, kind, &start, &end, suffix] {
367 hash.update(value.as_bytes());
368 hash.update([0]);
369 }
370 let digest = hash.finalize();
371 let mut encoded = String::with_capacity(24);
372 for byte in &digest[..12] {
373 use std::fmt::Write as _;
374 write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
375 }
376 format!("rs:{kind}:{encoded}")
377}
378
379struct RustObligationCollector<'a> {
380 file: &'a str,
381 locations: SourceLocations<'a>,
382 manifest: CoverageManifest,
383 point_ids: BTreeSet<String>,
384 decision_ids: BTreeSet<String>,
385 branch_ids: BTreeSet<String>,
386 limitation_ids: BTreeSet<&'static str>,
387 error: Option<RustInstrumenterError>,
388}
389
390impl<'a> RustObligationCollector<'a> {
391 fn new(file: &'a str, source: &'a str) -> Self {
392 Self {
393 file,
394 locations: SourceLocations::new(source),
395 manifest: CoverageManifest {
396 unmeasured: Vec::new(),
397 decisions: Vec::new(),
398 points: Vec::new(),
399 branches: Vec::new(),
400 limitations: Vec::new(),
401 scope: None,
402 },
403 point_ids: BTreeSet::new(),
404 decision_ids: BTreeSet::new(),
405 branch_ids: BTreeSet::new(),
406 limitation_ids: BTreeSet::new(),
407 error: None,
408 }
409 }
410
411 fn location_source(&mut self, range: TextRange) -> Option<(usize, usize, String)> {
412 let result = self.locations.range(range).map(|(start, _)| {
413 let (line, column) = self.locations.line_column(start);
414 (line, column, self.locations.text(range))
415 });
416 match result {
417 Ok((line, column, Ok(source))) => Some((line, column, source)),
418 Ok((_, _, Err(error))) | Err(error) => {
419 self.error.get_or_insert(error);
420 None
421 }
422 }
423 }
424
425 fn point(&mut self, range: TextRange, kind: PointKind, label: Option<String>) {
426 let kind_name = match kind {
427 PointKind::Statement => "statement",
428 PointKind::Function => "function",
429 };
430 let id = stable_id(self.file, kind_name, range, label.as_deref().unwrap_or(""));
431 if !self.point_ids.insert(id.clone()) {
432 return;
433 }
434 let Some((line, column, source)) = self.location_source(range) else {
435 return;
436 };
437 self.manifest.points.push(PointMeta {
438 id,
439 kind,
440 file: self.file.into(),
441 line,
442 column,
443 source,
444 label,
445 });
446 }
447
448 fn atomic_condition_ranges(expression: &ast::Expr, ranges: &mut Vec<TextRange>) {
449 match expression {
450 ast::Expr::ParenExpr(paren) => {
451 if let Some(inner) = paren.expr() {
452 Self::atomic_condition_ranges(&inner, ranges);
453 } else {
454 ranges.push(expression.syntax().text_range());
455 }
456 }
457 ast::Expr::BinExpr(binary)
458 if matches!(
459 binary.op_kind(),
460 Some(BinaryOp::LogicOp(LogicOp::And | LogicOp::Or))
461 ) =>
462 {
463 if let Some(left) = binary.lhs() {
464 Self::atomic_condition_ranges(&left, ranges);
465 }
466 if let Some(right) = binary.rhs() {
467 Self::atomic_condition_ranges(&right, ranges);
468 }
469 }
470 _ => ranges.push(expression.syntax().text_range()),
471 }
472 }
473
474 fn decision(&mut self, test: &ast::Expr, kind: &str) {
475 let range = test.syntax().text_range();
476 let id = stable_id(self.file, "decision", range, kind);
477 if !self.decision_ids.insert(id.clone()) {
478 return;
479 }
480 let Some((line, column, source)) = self.location_source(range) else {
481 return;
482 };
483 let mut condition_ranges = Vec::new();
484 Self::atomic_condition_ranges(test, &mut condition_ranges);
485 let mut conditions = Vec::with_capacity(condition_ranges.len());
486 for condition in condition_ranges {
487 match self.locations.text(condition) {
488 Ok(source) => conditions.push(source),
489 Err(error) => {
490 self.error.get_or_insert(error);
491 return;
492 }
493 }
494 }
495 self.manifest.decisions.push(DecisionMeta {
496 id: id.clone(),
497 file: self.file.into(),
498 line,
499 column,
500 source: source.clone(),
501 conditions,
502 kind: kind.into(),
503 });
504 self.branch_with_id(
505 format!("{id}:outcome"),
506 range,
507 kind,
508 source,
509 [("true", "true"), ("false", "false")],
510 );
511 }
512
513 fn branch<const N: usize>(
514 &mut self,
515 range: TextRange,
516 kind: &str,
517 alternatives: [(&str, &str); N],
518 ) {
519 let id = stable_id(self.file, "branch", range, kind);
520 let Some((_, _, source)) = self.location_source(range) else {
521 return;
522 };
523 self.branch_with_id(id, range, kind, source, alternatives);
524 }
525
526 fn branch_with_id<const N: usize>(
527 &mut self,
528 id: String,
529 range: TextRange,
530 kind: &str,
531 source: String,
532 alternatives: [(&str, &str); N],
533 ) {
534 if !self.branch_ids.insert(id.clone()) {
535 return;
536 }
537 let Some((line, column, _)) = self.location_source(range) else {
538 return;
539 };
540 self.manifest.branches.push(BranchMeta {
541 id: id.clone(),
542 kind: kind.into(),
543 file: self.file.into(),
544 line,
545 column,
546 source,
547 alternatives: alternatives
548 .into_iter()
549 .map(|(suffix, label)| BranchAlternativeMeta {
550 id: format!("{id}:{suffix}"),
551 label: label.into(),
552 })
553 .collect(),
554 });
555 }
556
557 fn limitation(&mut self, id: &'static str, reason: &'static str) {
558 if !self.limitation_ids.insert(id) {
559 return;
560 }
561 self.manifest.limitations.push(json!({
562 "id": id,
563 "kind": "rust-frontend-readiness",
564 "file": self.file,
565 "line": 1,
566 "column": 0,
567 "source": "",
568 "reason": reason
569 }));
570 }
571
572 fn collect(mut self, file: &SourceFile) -> Result<CoverageManifest, RustInstrumenterError> {
573 let root = file.syntax();
574
575 for list in root.descendants().filter_map(ast::StmtList::cast) {
576 for statement in list.statements() {
577 match statement {
578 ast::Stmt::ExprStmt(statement) => {
579 self.point(statement.syntax().text_range(), PointKind::Statement, None);
580 }
581 ast::Stmt::LetStmt(statement) => {
582 self.point(statement.syntax().text_range(), PointKind::Statement, None);
583 }
584 ast::Stmt::Item(_) => {}
585 }
586 }
587 if let Some(tail) = list.tail_expr() {
588 self.point(tail.syntax().text_range(), PointKind::Statement, None);
589 }
590 }
591
592 for function in root.descendants().filter_map(ast::Fn::cast) {
593 if function.body().is_none() {
594 continue;
595 }
596 if function.const_token().is_some() {
597 self.limitation(
598 "rust-const-context-not-instrumented",
599 "Runtime probes cannot execute in const fn or compile-time evaluation",
600 );
601 continue;
602 }
603 let label = function.name().map(|name| name.text().to_string());
604 self.point(function.syntax().text_range(), PointKind::Function, label);
605 }
606
607 for closure in root.descendants().filter_map(ast::ClosureExpr::cast) {
608 self.point(
609 closure.syntax().text_range(),
610 PointKind::Function,
611 Some("<closure>".into()),
612 );
613 }
614
615 for expression in root.descendants().filter_map(ast::IfExpr::cast) {
616 if let Some(condition) = expression.condition() {
617 self.decision(&condition, "if");
618 }
619 }
620 for expression in root.descendants().filter_map(ast::WhileExpr::cast) {
621 if let Some(condition) = expression.condition() {
622 self.decision(&condition, "while");
623 }
624 self.branch(
625 expression.syntax().text_range(),
626 "while-loop",
627 [("zero", "zero iterations"), ("entered", "entered")],
628 );
629 }
630 for guard in root.descendants().filter_map(ast::MatchGuard::cast) {
631 if let Some(condition) = guard.condition() {
632 self.decision(&condition, "match-guard");
633 }
634 }
635
636 for binary in root.descendants().filter_map(ast::BinExpr::cast) {
637 let kind = match binary.op_kind() {
638 Some(BinaryOp::LogicOp(LogicOp::And)) => "logical-and",
639 Some(BinaryOp::LogicOp(LogicOp::Or)) => "logical-or",
640 _ => continue,
641 };
642 let range = binary.rhs().map_or_else(
643 || binary.syntax().text_range(),
644 |right| right.syntax().text_range(),
645 );
646 self.branch(
647 range,
648 kind,
649 [
650 ("short-circuit", "short-circuited"),
651 ("evaluated", "right operand evaluated"),
652 ],
653 );
654 }
655
656 for expression in root.descendants().filter_map(ast::ForExpr::cast) {
657 self.branch(
658 expression.syntax().text_range(),
659 "for-loop",
660 [("zero", "zero iterations"), ("entered", "entered")],
661 );
662 }
663 for expression in root.descendants().filter_map(ast::MatchExpr::cast) {
664 let Some(list) = expression.match_arm_list() else {
665 continue;
666 };
667 let arms = list.arms().collect::<Vec<_>>();
668 let last = arms.len().saturating_sub(1);
669 for (index, arm) in arms.iter().enumerate() {
670 let range = arm.syntax().text_range();
671 if index == last {
672 self.branch(range, "match-arm", [("selected", "selected")]);
676 } else {
677 self.branch(
678 range,
679 "match-arm",
680 [("missed", "not selected"), ("selected", "selected")],
681 );
682 }
683 }
684 }
685 for expression in root.descendants().filter_map(ast::TryExpr::cast) {
686 self.branch(
687 expression.syntax().text_range(),
688 "try-operator",
689 [("continued", "continued"), ("returned", "early return")],
690 );
691 }
692
693 if root.descendants().any(|node| {
694 ast::MacroCall::can_cast(node.kind()) || ast::MacroExpr::can_cast(node.kind())
695 }) {
696 self.limitation(
697 "rust-macro-expansion-not-instrumented",
698 "Declarative and procedural macro expansions are not yet part of the owned source denominator",
699 );
700 }
701
702 let bears_obligation = |node: &ra_ap_syntax::SyntaxNode| {
707 ast::StmtList::cast(node.clone()).is_some_and(|list| {
708 list.statements().next().is_some() || list.tail_expr().is_some()
709 }) || ast::IfExpr::can_cast(node.kind())
710 || ast::WhileExpr::can_cast(node.kind())
711 || ast::MatchGuard::can_cast(node.kind())
712 || ast::ForExpr::can_cast(node.kind())
713 || ast::MatchArm::can_cast(node.kind())
714 || ast::TryExpr::can_cast(node.kind())
715 || ast::ClosureExpr::can_cast(node.kind())
716 || ast::BinExpr::cast(node.clone()).is_some_and(|binary| {
717 matches!(
718 binary.op_kind(),
719 Some(BinaryOp::LogicOp(LogicOp::And | LogicOp::Or))
720 )
721 })
722 };
723 if root
724 .descendants()
725 .any(|node| bears_obligation(&node) && in_const_context(&node))
726 {
727 self.limitation(
728 "rust-const-context-not-instrumented",
729 "Runtime probes cannot execute in const fn or compile-time evaluation",
730 );
731 }
732 if root
733 .descendants()
734 .any(|node| bears_obligation(&node) && in_global_allocator(&node))
735 {
736 self.limitation(
737 "rust-global-allocator-not-instrumented",
738 "Probing a GlobalAlloc implementation recurses into itself, because the runtime allocates",
739 );
740 }
741
742 if let Some(error) = self.error {
743 return Err(error);
744 }
745 self.manifest
746 .decisions
747 .sort_by(|left, right| left.id.cmp(&right.id));
748 self.manifest
749 .points
750 .sort_by(|left, right| left.id.cmp(&right.id));
751 self.manifest
752 .branches
753 .sort_by(|left, right| left.id.cmp(&right.id));
754 self.manifest.limitations.sort_by(|left, right| {
755 left.get("id")
756 .and_then(|value| value.as_str())
757 .cmp(&right.get("id").and_then(|value| value.as_str()))
758 });
759 Ok(self.manifest)
760 }
761}
762
763pub fn build_rust_manifest(
764 file: &str,
765 source: &str,
766) -> Result<CoverageManifest, RustInstrumenterError> {
767 if source.len() > u32::MAX as usize {
768 return Err(RustInstrumenterError::SourceTooLarge);
769 }
770 let parsed = SourceFile::parse(source, Edition::CURRENT);
771 let errors = parsed
772 .errors()
773 .into_iter()
774 .map(|error| error.to_string())
775 .collect::<Vec<_>>();
776 if !errors.is_empty() {
777 return Err(RustInstrumenterError::Parse(errors));
778 }
779 RustObligationCollector::new(file, source).collect(&parsed.tree())
780}
781
782fn block_entry_offset(block: &ast::BlockExpr) -> Option<usize> {
783 let list = block.stmt_list()?;
784 list.attrs()
785 .last()
786 .map(|attribute| usize::from(attribute.syntax().text_range().end()))
787 .or_else(|| {
788 list.l_curly_token()
789 .map(|token| usize::from(token.text_range().end()))
790 })
791}
792
793fn range_after_attributes(node: &impl HasAttrs) -> TextRange {
796 let range = node.syntax().text_range();
797 node.attrs().last().map_or(range, |attribute| {
798 TextRange::new(attribute.syntax().text_range().end(), range.end())
799 })
800}
801
802fn has_let(expression: &ast::Expr) -> bool {
803 expression
804 .syntax()
805 .descendants()
806 .any(|node| ast::LetExpr::can_cast(node.kind()))
807}
808
809enum ChainHost<'a> {
811 If(&'a ast::IfExpr),
812 While(&'a ast::WhileExpr),
813}
814
815fn allocate_chain_table_name(
817 file: &str,
818 condition: &ast::Expr,
819 identifiers: &mut BTreeSet<String>,
820) -> String {
821 let id = stable_id(file, "chain", condition.syntax().text_range(), "operators");
822 let suffix = id
823 .rsplit(':')
824 .next()
825 .unwrap_or("chain")
826 .to_ascii_uppercase();
827 let base = format!("__SUPERCOV_CHAIN_{suffix}");
828 let mut candidate = base.clone();
829 let mut attempt = 0_usize;
830 while !identifiers.insert(candidate.clone()) {
831 attempt += 1;
832 candidate = format!("{base}_{attempt}");
833 }
834 candidate
835}
836
837fn instrument_let_chain(
848 insertions: &mut Vec<Insertion>,
849 runtime_path: &str,
850 file: &str,
851 condition: &ast::Expr,
852 host: ChainHost<'_>,
853 identifiers: &mut BTreeSet<String>,
854) {
855 let (kind, host_range, body) = match &host {
858 ChainHost::If(expression) => (
859 "if",
860 range_after_attributes(*expression),
861 expression.then_branch(),
862 ),
863 ChainHost::While(expression) => (
864 "while",
865 range_after_attributes(*expression),
866 expression.loop_body(),
867 ),
868 };
869 let Some(body_offset) = body.as_ref().and_then(block_entry_offset) else {
870 return;
871 };
872 let range = condition.syntax().text_range();
873 let id = stable_id(file, "decision", range, kind);
874 let mut atoms = Vec::new();
875 RustObligationCollector::atomic_condition_ranges(condition, &mut atoms);
876 let lets = condition
877 .syntax()
878 .descendants()
879 .filter_map(ast::LetExpr::cast)
880 .map(|expression| expression.syntax().text_range())
881 .collect::<Vec<_>>();
882 let frame = allocate_frame_name(file, condition, kind, identifiers);
883 let table = allocate_chain_table_name(file, condition, identifiers);
884
885 let mut operators = Vec::new();
886 for binary in condition
887 .syntax()
888 .descendants()
889 .filter_map(ast::BinExpr::cast)
890 {
891 if !matches!(binary.op_kind(), Some(BinaryOp::LogicOp(LogicOp::And))) {
894 continue;
895 }
896 let (Some(left), Some(right)) = (binary.lhs(), binary.rhs()) else {
897 continue;
898 };
899 if !has_let(&left) {
900 continue;
901 }
902 let branch = stable_id(file, "branch", right.syntax().text_range(), "logical-and");
903 let right_range = right.syntax().text_range();
904 let Some(first) = atoms
905 .iter()
906 .position(|atom| right_range.contains_range(*atom))
907 else {
908 continue;
909 };
910 operators.push(format!(
911 "({first}, {:?}, {:?})",
912 format!("{branch}:short-circuit"),
913 format!("{branch}:evaluated")
914 ));
915 }
916
917 let prefix = format!(
918 "{{ const {table}: &[(usize, &str, &str)] = &[{}]; let mut {frame} = {runtime_path}::DecisionFrame::new({id:?}, {}); ",
919 operators.join(", "),
920 atoms.len()
921 );
922 let suffix = match &host {
923 ChainHost::If(expression) => match expression.else_branch() {
924 Some(_) => " }".to_owned(),
925 None => format!(
926 " else {{ {runtime_path}::decision_chain(&mut {frame}, false, {table}); }} }}"
927 ),
928 },
929 ChainHost::While(_) => {
930 format!(" {runtime_path}::decision_chain(&mut {frame}, false, {table}); }}")
931 }
932 };
933 push_wrapper(insertions, host_range, host_range, 1, prefix, suffix);
934 push_direct(
935 insertions,
936 usize::from(range.start()),
937 format!("{runtime_path}::reached(&mut {frame}, 0) && "),
938 );
939 for (index, atom) in atoms.iter().enumerate() {
940 if lets.contains(atom) {
941 if index > 0 {
942 push_direct(
943 insertions,
944 usize::from(atom.start()),
945 format!("{runtime_path}::reached(&mut {frame}, {index}) && "),
946 );
947 }
948 } else {
949 push_wrapper(
950 insertions,
951 *atom,
952 *atom,
953 1,
954 format!("{runtime_path}::condition(("),
955 format!("), &mut {frame}, {index})"),
956 );
957 }
958 }
959 push_direct(
960 insertions,
961 body_offset,
962 format!("\n{runtime_path}::decision_chain(&mut {frame}, true, {table});"),
963 );
964 if let ChainHost::If(expression) = &host {
965 match expression.else_branch() {
966 Some(ast::ElseBranch::Block(block)) => {
967 if let Some(offset) = block_entry_offset(&block) {
968 push_direct(
969 insertions,
970 offset,
971 format!("\n{runtime_path}::decision_chain(&mut {frame}, false, {table});"),
972 );
973 }
974 }
975 Some(ast::ElseBranch::IfExpr(nested)) => {
976 let nested_range = nested.syntax().text_range();
977 push_wrapper(
978 insertions,
979 nested_range,
980 nested_range,
981 0,
982 format!("{{ {runtime_path}::decision_chain(&mut {frame}, false, {table}); "),
983 " }".into(),
984 );
985 }
986 None => {}
987 }
988 }
989}
990
991fn enclosing_block_entry(node: &ra_ap_syntax::SyntaxNode) -> Option<usize> {
995 node.ancestors()
996 .skip(1)
997 .find_map(ast::BlockExpr::cast)
998 .and_then(|block| block_entry_offset(&block))
999}
1000
1001fn plain_block(block: &ast::BlockExpr) -> bool {
1005 block
1006 .syntax()
1007 .first_token()
1008 .is_some_and(|token| token.kind() == SyntaxKind::L_CURLY)
1009}
1010
1011fn instrument_decision(
1012 insertions: &mut Vec<Insertion>,
1013 runtime_path: &str,
1014 file: &str,
1015 condition: &ast::Expr,
1016 kind: &str,
1017 frame_name: &str,
1018) -> bool {
1019 if cannot_carry_probe(condition.syntax())
1020 || condition
1021 .syntax()
1022 .descendants()
1023 .any(|node| ast::LetExpr::can_cast(node.kind()))
1024 {
1025 return false;
1026 }
1027 let range = condition.syntax().text_range();
1028 let id = stable_id(file, "decision", range, kind);
1029 let mut condition_ranges = Vec::new();
1030 RustObligationCollector::atomic_condition_ranges(condition, &mut condition_ranges);
1031 push_wrapper(
1032 insertions,
1033 range,
1034 range,
1035 0,
1036 format!(
1037 "({{ let mut {frame_name} = {runtime_path}::DecisionFrame::new({id:?}, {}); {runtime_path}::decision((",
1038 condition_ranges.len()
1039 ),
1040 format!("), &mut {frame_name}) }})"),
1041 );
1042 for (index, atomic_range) in condition_ranges.into_iter().enumerate() {
1046 push_wrapper(
1047 insertions,
1048 atomic_range,
1049 atomic_range,
1050 1,
1051 format!("{runtime_path}::condition(("),
1052 format!("), &mut {frame_name}, {index})"),
1053 );
1054 }
1055 true
1056}
1057
1058pub fn instrument_rust_source(
1068 file: &str,
1069 source: &str,
1070 runtime_path: &str,
1071) -> Result<RustInstrumentedSource, RustInstrumenterError> {
1072 if !valid_runtime_path(runtime_path) {
1073 return Err(RustInstrumenterError::InvalidRuntimePath);
1074 }
1075 let mut manifest = build_rust_manifest(file, source)?;
1076 let parsed = SourceFile::parse(source, Edition::CURRENT);
1077 let tree = parsed.tree();
1078 let root = tree.syntax();
1079 let mut insertions = Vec::new();
1080 let mut identifiers = root
1081 .descendants_with_tokens()
1082 .filter_map(|element| element.into_token())
1083 .filter(|token| token.kind() == SyntaxKind::IDENT)
1084 .map(|token| token.text().to_string())
1085 .collect::<BTreeSet<_>>();
1086
1087 let mut skipped_attributed_statement = false;
1088 let attributed_probe = |insertions: &mut Vec<Insertion>,
1099 skipped: &mut bool,
1100 expression: Option<ast::Expr>,
1101 has_attrs: bool,
1102 range: TextRange,
1103 id: String| {
1104 if !has_attrs {
1105 push_direct(
1106 insertions,
1107 usize::from(range.start()),
1108 format!("{runtime_path}::hit({id:?});"),
1109 );
1110 return;
1111 }
1112 let Some(expression) = expression else {
1113 *skipped = true;
1114 return;
1115 };
1116 if let ast::Expr::BlockExpr(block) = &expression
1117 && let Some(offset) = block_entry_offset(block)
1118 {
1119 push_direct(
1120 insertions,
1121 offset,
1122 format!("\n{runtime_path}::hit({id:?});"),
1123 );
1124 return;
1125 }
1126 let start = expression.attrs().last().map_or_else(
1132 || expression.syntax().text_range().start(),
1133 |attribute| attribute.syntax().text_range().end(),
1134 );
1135 let wrapped = TextRange::new(start, expression.syntax().text_range().end());
1136 push_wrapper(
1137 insertions,
1138 wrapped,
1139 wrapped,
1140 0,
1141 format!(" {{ {runtime_path}::hit({id:?}); ("),
1142 ") }".into(),
1143 );
1144 };
1145 for list in root.descendants().filter_map(ast::StmtList::cast) {
1146 for statement in list.statements() {
1147 let (range, expression, has_attrs) = match statement {
1148 ast::Stmt::ExprStmt(statement) if !cannot_carry_probe(statement.syntax()) => {
1149 let expression = statement.expr();
1150 let has_attrs = expression
1153 .as_ref()
1154 .is_some_and(|expression| expression.attrs().next().is_some());
1155 (statement.syntax().text_range(), expression, has_attrs)
1156 }
1157 ast::Stmt::LetStmt(statement) if !cannot_carry_probe(statement.syntax()) => {
1158 let has_attrs = statement.attrs().next().is_some();
1159 let initializer = has_attrs.then(|| statement.initializer()).flatten();
1162 (statement.syntax().text_range(), initializer, has_attrs)
1163 }
1164 _ => continue,
1165 };
1166 let id = stable_id(file, "statement", range, "");
1167 attributed_probe(
1168 &mut insertions,
1169 &mut skipped_attributed_statement,
1170 expression,
1171 has_attrs,
1172 range,
1173 id,
1174 );
1175 }
1176 if let Some(tail) = list
1177 .tail_expr()
1178 .filter(|tail| !cannot_carry_probe(tail.syntax()))
1179 {
1180 let range = tail.syntax().text_range();
1181 let id = stable_id(file, "statement", range, "");
1182 let has_attrs = tail.attrs().next().is_some();
1183 attributed_probe(
1184 &mut insertions,
1185 &mut skipped_attributed_statement,
1186 Some(tail),
1187 has_attrs,
1188 range,
1189 id,
1190 );
1191 }
1192 }
1193
1194 for function in root.descendants().filter_map(ast::Fn::cast) {
1195 if cannot_carry_probe(function.syntax()) {
1198 continue;
1199 }
1200 let Some(body) = function.body() else {
1201 continue;
1202 };
1203 let label = function.name().map(|name| name.text().to_string());
1204 let id = stable_id(
1205 file,
1206 "function",
1207 function.syntax().text_range(),
1208 label.as_deref().unwrap_or(""),
1209 );
1210 if let Some(offset) = block_entry_offset(&body) {
1211 push_direct(
1212 &mut insertions,
1213 offset,
1214 format!("\n{runtime_path}::hit({id:?});"),
1215 );
1216 }
1217 }
1218
1219 for closure in root.descendants().filter_map(ast::ClosureExpr::cast) {
1220 let Some(body) = closure.body() else {
1221 continue;
1222 };
1223 if cannot_carry_probe(body.syntax()) {
1224 continue;
1225 }
1226 let id = stable_id(file, "function", closure.syntax().text_range(), "<closure>");
1227 if let ast::Expr::BlockExpr(block) = &body {
1228 if let Some(offset) = block_entry_offset(block) {
1229 push_direct(
1230 &mut insertions,
1231 offset,
1232 format!("\n{runtime_path}::hit({id:?});"),
1233 );
1234 }
1235 } else {
1236 let range = body.syntax().text_range();
1237 push_wrapper(
1238 &mut insertions,
1239 range,
1240 closure.syntax().text_range(),
1241 0,
1242 format!("{{ {runtime_path}::hit({id:?}); ("),
1243 ") }".into(),
1244 );
1245 }
1246 }
1247
1248 for expression in root.descendants().filter_map(ast::MatchExpr::cast) {
1255 if cannot_carry_probe(expression.syntax()) {
1256 continue;
1257 }
1258 let Some(list) = expression.match_arm_list() else {
1259 continue;
1260 };
1261 let arms = list.arms().collect::<Vec<_>>();
1262 if arms.is_empty() {
1263 continue;
1264 }
1265 let Some(table_offset) = enclosing_block_entry(expression.syntax()) else {
1266 continue;
1267 };
1268 let table = allocate_table_name(file, &expression, &mut identifiers);
1269 let entries = arms
1270 .iter()
1271 .map(|arm| {
1272 let id = stable_id(file, "branch", arm.syntax().text_range(), "match-arm");
1273 format!(
1274 "{:?}, {:?}",
1275 format!("{id}:missed"),
1276 format!("{id}:selected")
1277 )
1278 })
1279 .collect::<Vec<_>>()
1280 .join(", ");
1281 push_direct(
1282 &mut insertions,
1283 table_offset,
1284 format!("\nconst {table}: &[&str] = &[{entries}];"),
1285 );
1286 for (index, arm) in arms.iter().enumerate() {
1287 let Some(body) = arm.expr() else {
1288 continue;
1289 };
1290 let call = format!("{runtime_path}::arms({table}, {index});");
1291 match &body {
1292 ast::Expr::BlockExpr(block) if plain_block(block) => {
1293 if let Some(offset) = block_entry_offset(block) {
1294 push_direct(&mut insertions, offset, format!("\n{call}"));
1295 }
1296 }
1297 _ => push_wrapper(
1298 &mut insertions,
1299 body.syntax().text_range(),
1300 arm.syntax().text_range(),
1301 0,
1302 format!("{{ {call} ("),
1303 ") }".into(),
1304 ),
1305 }
1306 }
1307 }
1308
1309 for binary in root.descendants().filter_map(ast::BinExpr::cast) {
1313 let short_circuits_when = match binary.op_kind() {
1314 Some(BinaryOp::LogicOp(LogicOp::And)) => false,
1315 Some(BinaryOp::LogicOp(LogicOp::Or)) => true,
1316 _ => continue,
1317 };
1318 if cannot_carry_probe(binary.syntax()) {
1319 continue;
1320 }
1321 let (Some(left), Some(right)) = (binary.lhs(), binary.rhs()) else {
1322 continue;
1323 };
1324 if has_let(&left) {
1328 continue;
1329 }
1330 let kind = if short_circuits_when {
1331 "logical-or"
1332 } else {
1333 "logical-and"
1334 };
1335 let id = stable_id(file, "branch", right.syntax().text_range(), kind);
1336 push_wrapper(
1337 &mut insertions,
1338 left.syntax().text_range(),
1339 binary.syntax().text_range(),
1340 2,
1341 format!("{runtime_path}::logical(("),
1342 format!(
1343 "), {short_circuits_when}, {:?}, {:?})",
1344 format!("{id}:short-circuit"),
1345 format!("{id}:evaluated")
1346 ),
1347 );
1348 }
1349
1350 for expression in root.descendants().filter_map(ast::ForExpr::cast) {
1354 if cannot_carry_probe(expression.syntax()) {
1355 continue;
1356 }
1357 let Some(iterable) = expression.iterable() else {
1358 continue;
1359 };
1360 let id = stable_id(file, "branch", expression.syntax().text_range(), "for-loop");
1361 push_wrapper(
1365 &mut insertions,
1366 iterable.syntax().text_range(),
1367 iterable.syntax().text_range(),
1368 0,
1369 format!("{runtime_path}::for_loop(("),
1370 format!(
1371 "), {:?}, {:?})",
1372 format!("{id}:zero"),
1373 format!("{id}:entered")
1374 ),
1375 );
1376 }
1377
1378 for expression in root.descendants().filter_map(ast::WhileExpr::cast) {
1382 if cannot_carry_probe(expression.syntax()) {
1383 continue;
1384 }
1385 let Some(offset) = expression.loop_body().as_ref().and_then(block_entry_offset) else {
1386 continue;
1387 };
1388 let id = stable_id(
1389 file,
1390 "branch",
1391 expression.syntax().text_range(),
1392 "while-loop",
1393 );
1394 let flag = allocate_flag_name(file, &expression, &mut identifiers);
1395 let range = range_after_attributes(&expression);
1396 push_wrapper(
1397 &mut insertions,
1398 range,
1399 range,
1400 0,
1401 format!("{{ let mut {flag} = true; "),
1402 format!(
1403 " {runtime_path}::zero_iterations({flag}, {:?}) }}",
1404 format!("{id}:zero")
1405 ),
1406 );
1407 push_direct(
1408 &mut insertions,
1409 offset,
1410 format!(
1411 "\n{runtime_path}::entered(&mut {flag}, {:?});",
1412 format!("{id}:entered")
1413 ),
1414 );
1415 }
1416
1417 for expression in root.descendants().filter_map(ast::TryExpr::cast) {
1421 if cannot_carry_probe(expression.syntax()) {
1422 continue;
1423 }
1424 let Some(operand) = expression.expr() else {
1425 continue;
1426 };
1427 let id = stable_id(
1428 file,
1429 "branch",
1430 expression.syntax().text_range(),
1431 "try-operator",
1432 );
1433 push_wrapper(
1436 &mut insertions,
1437 operand.syntax().text_range(),
1438 operand.syntax().text_range(),
1439 0,
1440 format!("{runtime_path}::TryProbe::probe(("),
1441 format!(
1442 "), {:?}, {:?})",
1443 format!("{id}:continued"),
1444 format!("{id}:returned")
1445 ),
1446 );
1447 }
1448
1449 for expression in root.descendants().filter_map(ast::IfExpr::cast) {
1450 let Some(condition) = expression.condition() else {
1451 continue;
1452 };
1453 if has_let(&condition) {
1454 if !cannot_carry_probe(condition.syntax()) {
1455 instrument_let_chain(
1456 &mut insertions,
1457 runtime_path,
1458 file,
1459 &condition,
1460 ChainHost::If(&expression),
1461 &mut identifiers,
1462 );
1463 }
1464 continue;
1465 }
1466 let frame_name = allocate_frame_name(file, &condition, "if", &mut identifiers);
1467 instrument_decision(
1468 &mut insertions,
1469 runtime_path,
1470 file,
1471 &condition,
1472 "if",
1473 &frame_name,
1474 );
1475 }
1476 for expression in root.descendants().filter_map(ast::WhileExpr::cast) {
1477 let Some(condition) = expression.condition() else {
1478 continue;
1479 };
1480 if has_let(&condition) {
1481 if !cannot_carry_probe(condition.syntax()) {
1482 instrument_let_chain(
1483 &mut insertions,
1484 runtime_path,
1485 file,
1486 &condition,
1487 ChainHost::While(&expression),
1488 &mut identifiers,
1489 );
1490 }
1491 continue;
1492 }
1493 let frame_name = allocate_frame_name(file, &condition, "while", &mut identifiers);
1494 instrument_decision(
1495 &mut insertions,
1496 runtime_path,
1497 file,
1498 &condition,
1499 "while",
1500 &frame_name,
1501 );
1502 }
1503 for guard in root.descendants().filter_map(ast::MatchGuard::cast) {
1504 if let Some(condition) = guard.condition() {
1505 let frame_name = allocate_frame_name(file, &condition, "match-guard", &mut identifiers);
1506 instrument_decision(
1507 &mut insertions,
1508 runtime_path,
1509 file,
1510 &condition,
1511 "match-guard",
1512 &frame_name,
1513 );
1514 }
1515 }
1516
1517 if skipped_attributed_statement {
1518 add_manifest_limitation(
1519 &mut manifest,
1520 file,
1521 "rust-attributed-statement-probes-not-injected",
1522 "A `let` without an initializer that carries outer attributes has no expression to hold a probe",
1523 );
1524 }
1525 manifest.limitations.sort_by(|left, right| {
1526 left.get("id")
1527 .and_then(|value| value.as_str())
1528 .cmp(&right.get("id").and_then(|value| value.as_str()))
1529 });
1530
1531 let code = apply_insertions(source, insertions)?;
1532 let transformed = SourceFile::parse(&code, Edition::CURRENT);
1533 let errors = transformed
1534 .errors()
1535 .into_iter()
1536 .map(|error| error.to_string())
1537 .collect::<Vec<_>>();
1538 if !errors.is_empty() {
1539 return Err(RustInstrumenterError::Parse(errors));
1540 }
1541 Ok(RustInstrumentedSource { code, manifest })
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546 use std::{
1547 fs,
1548 process::Command,
1549 time::{SystemTime, UNIX_EPOCH},
1550 };
1551
1552 use super::*;
1553
1554 const NOOP_RUNTIME: &str = r#"
1555#[doc(hidden)]
1556mod __supercov_runtime_v1 {
1557 pub struct DecisionFrame;
1558 impl DecisionFrame {
1559 pub fn new(_: &'static str, _: usize) -> Self { Self }
1560 }
1561 pub fn hit(_: &'static str) {}
1562 pub fn arms(_: &[&'static str], _: usize) {}
1563 pub fn logical(left: bool, _: bool, _: &'static str, _: &'static str) -> bool { left }
1564 pub fn for_loop<I: IntoIterator>(iterable: I, _: &'static str, _: &'static str) -> I::IntoIter {
1565 iterable.into_iter()
1566 }
1567 pub fn entered(_: &mut bool, _: &'static str) {}
1568 pub fn zero_iterations(_: bool, _: &'static str) {}
1569 pub trait TryProbe: Sized {
1570 fn probe(self, _: &'static str, _: &'static str) -> Self { self }
1571 }
1572 impl<T> TryProbe for T {}
1573 pub fn condition(value: bool, _: &mut DecisionFrame, _: usize) -> bool { value }
1574 pub fn decision(value: bool, _: &mut DecisionFrame) -> bool { value }
1575 pub fn reached(_: &mut DecisionFrame, _: usize) -> bool { true }
1576 pub fn decision_chain(_: &mut DecisionFrame, _: bool, _: &[(usize, &'static str, &'static str)]) {}
1577}
1578"#;
1579
1580 fn compile_and_run(source: &str, name: &str) -> std::process::Output {
1581 let nonce = SystemTime::now()
1582 .duration_since(UNIX_EPOCH)
1583 .unwrap()
1584 .as_nanos();
1585 let directory = std::env::temp_dir().join(format!(
1586 "supercov-rust-transform-{}-{nonce}-{name}",
1587 std::process::id()
1588 ));
1589 fs::create_dir(&directory).unwrap();
1590 let input = directory.join("main.rs");
1591 let binary = directory.join("program");
1592 fs::write(&input, source).unwrap();
1593 let compile = Command::new("rustc")
1594 .arg("--edition=2024")
1595 .arg(&input)
1596 .arg("-o")
1597 .arg(&binary)
1598 .output()
1599 .unwrap();
1600 assert!(
1601 compile.status.success(),
1602 "rustc failed:\n{}\nsource:\n{source}",
1603 String::from_utf8_lossy(&compile.stderr)
1604 );
1605 let output = Command::new(&binary).output().unwrap();
1606 fs::remove_dir_all(directory).unwrap();
1607 output
1608 }
1609
1610 #[test]
1611 fn discovers_rust_obligations_with_exact_ranges_and_stable_ids() {
1612 let source = r#"fn classify<T>(values: &[T], first: bool, second: bool, third: bool) -> Option<&T> {
1613 let picked = if first && (second || third) {
1614 values.first()?
1615 } else {
1616 None
1617 };
1618 for value in values {
1619 if first || second {
1620 return Some(value);
1621 }
1622 }
1623 match picked {
1624 Some(value) if second && third => Some(value),
1625 _ => None,
1626 }
1627}
1628
1629fn closure(value: i32) -> bool {
1630 (|candidate| candidate > 0)(value)
1631}
1632"#;
1633 let first = build_rust_manifest("src/lib.rs", source).unwrap();
1634 let second = build_rust_manifest("src/lib.rs", source).unwrap();
1635 assert_eq!(first, second);
1636 assert!(first.points.iter().any(|point| {
1637 point.kind == PointKind::Function && point.label.as_deref() == Some("classify")
1638 }));
1639 assert!(first.points.iter().any(|point| {
1640 point.kind == PointKind::Function && point.label.as_deref() == Some("<closure>")
1641 }));
1642 let first_if = first
1643 .decisions
1644 .iter()
1645 .find(|decision| decision.line == 2)
1646 .unwrap();
1647 assert_eq!(first_if.conditions, ["first", "second", "third"]);
1648 assert_eq!(first_if.column, 20);
1649 assert!(
1650 first
1651 .branches
1652 .iter()
1653 .any(|branch| branch.kind == "for-loop")
1654 );
1655 let mut arms = first
1656 .branches
1657 .iter()
1658 .filter(|branch| branch.kind == "match-arm")
1659 .collect::<Vec<_>>();
1660 arms.sort_by_key(|branch| branch.line);
1661 assert_eq!(arms.len(), 2);
1662 assert_eq!(
1663 arms[0]
1664 .alternatives
1665 .iter()
1666 .map(|alternative| alternative.label.as_str())
1667 .collect::<Vec<_>>(),
1668 ["not selected", "selected"]
1669 );
1670 assert_eq!(
1673 arms[1]
1674 .alternatives
1675 .iter()
1676 .map(|alternative| alternative.label.as_str())
1677 .collect::<Vec<_>>(),
1678 ["selected"]
1679 );
1680 assert!(
1681 first
1682 .branches
1683 .iter()
1684 .any(|branch| branch.kind == "try-operator")
1685 );
1686 assert!(first.decisions.iter().all(|decision| {
1687 decision.id.starts_with("rs:decision:") && decision.conditions.len() >= 2
1688 }));
1689 assert!(first.limitations.is_empty());
1690 }
1691
1692 #[test]
1693 fn declares_macro_and_const_boundaries_instead_of_hiding_them() {
1694 let source = r#"const fn doubled(value: usize) -> usize { value * 2 }
1695
1696fn checked(value: bool) -> bool {
1697 assert!(value);
1698 const { doubled(2) == 4 }
1699}
1700"#;
1701 let manifest = build_rust_manifest("src/lib.rs", source).unwrap();
1702 let ids = manifest
1703 .limitations
1704 .iter()
1705 .filter_map(|limitation| limitation.get("id")?.as_str())
1706 .collect::<BTreeSet<_>>();
1707 assert_eq!(
1708 ids,
1709 BTreeSet::from([
1710 "rust-const-context-not-instrumented",
1711 "rust-macro-expansion-not-instrumented"
1712 ])
1713 );
1714 assert!(!manifest.points.iter().any(|point| {
1715 point.kind == PointKind::Function && point.label.as_deref() == Some("doubled")
1716 }));
1717 }
1718
1719 #[test]
1720 fn transforms_points_and_nested_decisions_without_changing_behavior() {
1721 let source = r#"use std::sync::atomic::{AtomicUsize, Ordering};
1722
1723static CALLS: AtomicUsize = AtomicUsize::new(0);
1724
1725fn observed(name: &str, value: bool) -> bool {
1726 let order = CALLS.fetch_add(1, Ordering::SeqCst);
1727 println!("{order}:{name}:{value}");
1728 value
1729}
1730
1731fn classify(first: bool, second: bool, third: bool) -> i32 {
1732 if observed("a", first) && (observed("b", second) || observed("c", third)) {
1733 7
1734 } else {
1735 3
1736 }
1737}
1738
1739fn main() {
1740 let closure = |value: i32| value + 1;
1741 println!("result={}", closure(classify(true, false, true)));
1742}
1743"#;
1744 let transformed =
1745 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1746 assert!(transformed.code.contains("::condition("));
1747 assert!(transformed.code.contains("::decision("));
1748 assert!(transformed.code.contains("::hit("));
1749 let original = compile_and_run(source, "original");
1750 let instrumented = compile_and_run(
1751 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1752 "instrumented",
1753 );
1754 assert_eq!(instrumented.status, original.status);
1755 assert_eq!(instrumented.stdout, original.stdout);
1756 assert_eq!(instrumented.stderr, original.stderr);
1757 }
1758
1759 #[test]
1760 fn let_chains_take_derived_condition_probes_and_const_contexts_stay_declared() {
1761 let source = r#"const fn enabled(value: bool) -> bool {
1762 if value { true } else { false }
1763}
1764
1765fn classify(value: Option<bool>, fallback: bool) -> bool {
1766 if let Some(inner) = value && inner && fallback { true } else { false }
1767}
1768"#;
1769 let transformed =
1770 instrument_rust_source("src/lib.rs", source, "crate::__supercov_runtime_v1").unwrap();
1771 let ids = transformed
1772 .manifest
1773 .limitations
1774 .iter()
1775 .filter_map(|limitation| limitation.get("id")?.as_str())
1776 .collect::<BTreeSet<_>>();
1777 assert!(ids.contains("rust-const-context-not-instrumented"));
1778 assert!(!ids.contains("rust-let-chain-probes-not-injected"));
1779 assert!(
1782 transformed
1783 .code
1784 .contains("::reached(&mut __supercov_decision_")
1785 );
1786 assert!(
1787 transformed
1788 .code
1789 .contains("::condition((inner), &mut __supercov_decision_")
1790 );
1791 assert!(
1792 transformed
1793 .code
1794 .contains("::decision_chain(&mut __supercov_decision_")
1795 );
1796 assert!(transformed.code.contains("&& let Some(inner) = value &&"));
1797 assert!(!transformed.code.contains("condition((let"));
1798 }
1799
1800 #[test]
1801 fn let_chains_keep_their_behavior() {
1802 let source = r#"fn describe(value: Option<i32>, flag: bool) -> &'static str {
1803 if let Some(inner) = value && inner > 0 && flag {
1804 "positive"
1805 } else if let Some(inner) = value && (inner < 0 || flag) {
1806 "negative-or-flagged"
1807 } else {
1808 "other"
1809 }
1810}
1811
1812fn count_pairs(values: &[(Option<i32>, i32)]) -> i32 {
1813 let mut total = 0;
1814 let mut it = values.iter();
1815 while let Some((first, second)) = it.next() && let Some(inner) = first && *second > 0 {
1816 total += inner * second;
1817 if total > 100 {
1818 break;
1819 }
1820 }
1821 total
1822}
1823
1824fn tail(value: Option<&str>) -> usize {
1825 let pick = |v: Option<&str>| if let Some(text) = v && !text.is_empty() { text.len() } else { 0 };
1826 if let Some(text) = value && text.starts_with('x') {
1827 println!("x-prefixed");
1828 }
1829 pick(value)
1830}
1831
1832fn main() {
1833 for value in [Some(3), Some(-3), Some(0), None] {
1834 for flag in [true, false] {
1835 println!("{value:?} {flag} {}", describe(value, flag));
1836 }
1837 }
1838 println!("{}", count_pairs(&[(Some(2), 3), (Some(4), 5), (None, 1), (Some(9), 9)]));
1839 println!("{}", count_pairs(&[(Some(50), 3), (Some(4), 5)]));
1840 println!("{} {} {}", tail(Some("xyz")), tail(Some("")), tail(None));
1841}
1842"#;
1843 let transformed =
1844 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1845 assert_eq!(
1846 transformed.code.matches("const __SUPERCOV_CHAIN_").count(),
1847 5
1848 );
1849 assert!(!transformed.manifest.limitations.iter().any(|limitation| {
1850 limitation.get("id").and_then(|id| id.as_str())
1851 == Some("rust-let-chain-probes-not-injected")
1852 }));
1853 let original = compile_and_run(source, "original-chains");
1854 let instrumented = compile_and_run(
1855 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1856 "instrumented-chains",
1857 );
1858 assert_eq!(instrumented.status, original.status);
1859 assert_eq!(instrumented.stdout, original.stdout);
1860 assert_eq!(instrumented.stderr, original.stderr);
1861 }
1862
1863 #[test]
1864 fn instrumented_const_and_static_initialisers_still_compile() {
1865 let source = r#"const DIRECT: usize = if cfg!(unix) { 100 } else { 1_000 };
1871static WIDTH: usize = if cfg!(unix) { 2 } else { 4 };
1872
1873enum Mode {
1874 Narrow = if cfg!(unix) { 1 } else { 2 },
1875}
1876
1877struct Buffer([u8; if cfg!(unix) { 4 } else { 8 }]);
1878
1879impl Buffer {
1880 const SPAN: usize = if cfg!(unix) { 5 } else { 9 };
1881}
1882
1883fn scaled(flag: bool) -> usize {
1884 const LOCAL: usize = if cfg!(unix) { 3 } else { 6 };
1885 if flag { LOCAL + Buffer::SPAN } else { DIRECT + WIDTH }
1886}
1887
1888fn main() {
1889 let buffer = Buffer([0; if cfg!(unix) { 4 } else { 8 }]);
1890 println!(
1891 "{} {} {} {}",
1892 scaled(true),
1893 scaled(false),
1894 Mode::Narrow as usize,
1895 buffer.0.len()
1896 );
1897}
1898"#;
1899 let transformed =
1900 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1901 assert!(transformed.code.contains("::decision("));
1904 let ids = transformed
1905 .manifest
1906 .limitations
1907 .iter()
1908 .filter_map(|limitation| limitation.get("id")?.as_str())
1909 .collect::<BTreeSet<_>>();
1910 assert!(ids.contains("rust-const-context-not-instrumented"));
1911
1912 let original = compile_and_run(source, "const-original");
1913 let instrumented = compile_and_run(
1914 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1915 "const-instrumented",
1916 );
1917 assert_eq!(instrumented.status, original.status);
1918 assert_eq!(instrumented.stdout, original.stdout);
1919 assert_eq!(instrumented.stderr, original.stderr);
1920 }
1921
1922 #[test]
1923 fn a_probed_global_allocator_would_recurse_into_itself() {
1924 let source = r#"use std::alloc::{GlobalAlloc, Layout, System};
1930
1931struct Odd;
1932
1933unsafe impl GlobalAlloc for Odd {
1934 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
1935 if layout.align() == 1 && layout.size() > 0 {
1936 System.alloc(layout)
1937 } else {
1938 System.alloc(layout)
1939 }
1940 }
1941
1942 unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
1943 System.dealloc(pointer, layout);
1944 }
1945}
1946
1947#[global_allocator]
1948static ODD: Odd = Odd;
1949
1950fn classify(flag: bool) -> usize {
1951 if flag { 1 } else { 2 }
1952}
1953
1954fn main() {
1955 let held = std::vec![7u8; 32];
1956 println!("{} {}", classify(!held.is_empty()), held.len());
1957}
1958"#;
1959 let transformed =
1960 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1961 let allocator = transformed
1963 .code
1964 .split("unsafe impl GlobalAlloc for Odd")
1965 .nth(1)
1966 .and_then(|rest| rest.split("#[global_allocator]").next())
1967 .expect("the instrumented source still contains the allocator impl");
1968 assert!(
1969 !allocator.contains("__supercov_runtime_v1"),
1970 "probe injected into a GlobalAlloc impl:\n{allocator}"
1971 );
1972 assert!(transformed.code.contains("::decision("));
1974 let ids = transformed
1975 .manifest
1976 .limitations
1977 .iter()
1978 .filter_map(|limitation| limitation.get("id")?.as_str())
1979 .collect::<BTreeSet<_>>();
1980 assert!(ids.contains("rust-global-allocator-not-instrumented"));
1981
1982 let original = compile_and_run(source, "alloc-original");
1983 let instrumented = compile_and_run(
1984 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
1985 "alloc-instrumented",
1986 );
1987 assert_eq!(instrumented.status, original.status);
1988 assert_eq!(instrumented.stdout, original.stdout);
1989 assert_eq!(instrumented.stderr, original.stderr);
1990 }
1991
1992 #[test]
1993 fn match_arms_record_selection_without_changing_behavior() {
1994 let source = r#"#[derive(Debug)]
1995enum Shape { Dot, Line(i32), Box { w: i32, h: i32 } }
1996
1997fn area(shape: &Shape) -> i32 {
1998 match shape {
1999 Shape::Dot => 0,
2000 Shape::Line(length) if *length < 0 => -length,
2001 Shape::Line(length) => *length,
2002 Shape::Box { w, h } => {
2003 let area = w * h;
2004 area
2005 }
2006 }
2007}
2008
2009fn describe(value: i32) -> &'static str {
2010 let inner = |v: i32| match v { 0 => "none", 1 => "one", _ => "many" };
2011 match value {
2012 0 => inner(value),
2013 n if n < 0 => unsafe { std::hint::unreachable_unchecked() },
2014 n => match n % 2 {
2015 0 => "even",
2016 _ => inner(n),
2017 },
2018 }
2019}
2020
2021fn main() {
2022 for shape in [Shape::Dot, Shape::Line(-3), Shape::Line(4), Shape::Box { w: 2, h: 5 }] {
2023 println!("{shape:?}={}", area(&shape));
2024 }
2025 for value in [0, 1, 3, 8] {
2026 println!("{value}:{}", describe(value));
2027 }
2028}
2029"#;
2030 let transformed =
2031 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
2032 assert!(transformed.code.contains("::arms(__SUPERCOV_ARMS_"));
2033 assert_eq!(
2034 transformed.code.matches("const __SUPERCOV_ARMS_").count(),
2035 4
2036 );
2037 let arms = transformed
2038 .manifest
2039 .branches
2040 .iter()
2041 .filter(|branch| branch.kind == "match-arm")
2042 .count();
2043 assert_eq!(arms, 4 + 3 + 3 + 2);
2044 for branch in transformed
2047 .manifest
2048 .branches
2049 .iter()
2050 .filter(|branch| branch.kind == "match-arm")
2051 {
2052 for alternative in &branch.alternatives {
2053 assert!(
2054 transformed.code.contains(&format!("{:?}", alternative.id)),
2055 "{} is not in any table",
2056 alternative.id
2057 );
2058 }
2059 }
2060 let original = compile_and_run(source, "original-arms");
2061 let instrumented = compile_and_run(
2062 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
2063 "instrumented-arms",
2064 );
2065 assert_eq!(instrumented.status, original.status);
2066 assert_eq!(instrumented.stdout, original.stdout);
2067 assert_eq!(instrumented.stderr, original.stderr);
2068 }
2069
2070 #[test]
2071 fn loops_logic_and_try_record_their_branches_without_changing_behavior() {
2072 let source = r#"use std::ops::ControlFlow;
2073
2074fn total(values: &[i32]) -> i32 {
2075 let mut sum = 0;
2076 for value in values {
2077 sum += value;
2078 }
2079 'outer: for row in 0..3 {
2080 for column in 0..3 {
2081 if column > row {
2082 continue 'outer;
2083 }
2084 sum += row * column;
2085 }
2086 }
2087 sum
2088}
2089
2090fn first_even(values: &[i32]) -> Option<i32> {
2091 let mut index = 0;
2092 'scan: while index < values.len() {
2093 if values[index] % 2 == 0 {
2094 break 'scan;
2095 }
2096 index += 1;
2097 }
2098 let mut it = values.iter().skip(index);
2099 while let Some(value) = it.next() {
2100 return Some(*value);
2101 }
2102 None
2103}
2104
2105fn parse_twice(text: &str) -> Result<i32, String> {
2106 let value: i32 = text.trim().parse().map_err(|_| "bad".to_string())?;
2107 let doubled = Some(value).map(|v| v * 2).ok_or("none")?;
2108 Ok(doubled)
2109}
2110
2111fn halve(value: i32) -> Option<i32> {
2112 let even = (value % 2 == 0).then_some(value)?;
2113 Some(even / 2)
2114}
2115
2116fn flow(values: &[i32]) -> ControlFlow<i32, i32> {
2117 let mut sum = 0;
2118 for value in values {
2119 let step: ControlFlow<i32, i32> = if *value < 0 { ControlFlow::Break(*value) } else { ControlFlow::Continue(*value) };
2120 sum += step?;
2121 }
2122 ControlFlow::Continue(sum)
2123}
2124
2125fn gate(a: bool, b: bool, c: bool) -> bool {
2126 let both = a && b;
2127 let either = a || b || c;
2128 both || (either && !c) || (c && a && (b || !b))
2129}
2130
2131fn main() {
2132 println!("{} {}", total(&[]), total(&[1, 2, 3]));
2133 println!("{:?} {:?} {:?}", first_even(&[]), first_even(&[1, 3]), first_even(&[1, 4, 6]));
2134 println!("{:?} {:?}", parse_twice(" 21 "), parse_twice("x"));
2135 println!("{:?} {:?}", halve(8), halve(7));
2136 println!("{:?} {:?}", flow(&[1, 2]), flow(&[1, -5, 2]));
2137 for a in [false, true] {
2138 for b in [false, true] {
2139 for c in [false, true] {
2140 print!("{}", gate(a, b, c) as u8);
2141 }
2142 }
2143 }
2144 println!();
2145}
2146"#;
2147 let transformed =
2148 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
2149 for marker in [
2150 "::logical((",
2151 "::for_loop((",
2152 "::entered(&mut __supercov_loop_",
2153 "::zero_iterations(__supercov_loop_",
2154 "::TryProbe::probe((",
2155 ] {
2156 assert!(transformed.code.contains(marker), "{marker} missing");
2157 }
2158 let kinds = |kind: &str| {
2159 transformed
2160 .manifest
2161 .branches
2162 .iter()
2163 .filter(|branch| branch.kind == kind)
2164 .count()
2165 };
2166 assert_eq!(kinds("for-loop"), 3 + 1 + 3);
2167 assert_eq!(kinds("while-loop"), 2);
2168 assert_eq!(kinds("try-operator"), 4);
2169 assert_eq!(kinds("logical-and"), 4);
2170 assert_eq!(kinds("logical-or"), 5);
2171 assert!(!transformed.manifest.limitations.iter().any(|limitation| {
2172 limitation.get("id").and_then(|id| id.as_str())
2173 == Some("rust-structural-branch-probes-not-yet-injected")
2174 }));
2175 let original = compile_and_run(source, "original-structural");
2176 let instrumented = compile_and_run(
2177 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
2178 "instrumented-structural",
2179 );
2180 assert_eq!(instrumented.status, original.status);
2181 assert_eq!(instrumented.stdout, original.stdout);
2182 assert_eq!(instrumented.stderr, original.stderr);
2183 }
2184
2185 #[test]
2186 fn cfg_gated_sibling_blocks_keep_their_tail_position() {
2187 let source = r#"pub fn is_available() -> bool {
2193 #[cfg(target_endian = "little")]
2194 {
2195 true
2196 }
2197 #[cfg(not(target_endian = "little"))]
2198 {
2199 false
2200 }
2201}
2202
2203fn main() {
2204 println!("{}", is_available());
2205}
2206"#;
2207 let transformed =
2208 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
2209 let original = compile_and_run(source, "cfg-original");
2210 let instrumented = compile_and_run(
2211 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
2212 "cfg-instrumented",
2213 );
2214 assert_eq!(instrumented.status, original.status);
2215 assert_eq!(instrumented.stdout, original.stdout);
2216 assert!(
2218 transformed
2219 .code
2220 .contains("{\n\ncrate::__supercov_runtime_v1::hit(")
2221 || transformed
2222 .code
2223 .contains("{\ncrate::__supercov_runtime_v1::hit(")
2224 );
2225
2226 let attributed_let = r#"fn main() {
2230 #[cfg(target_endian = "little")]
2231 let value = 1;
2232 #[cfg(not(target_endian = "little"))]
2233 let value = 2;
2234 #[cfg(target_endian = "little")]
2235 let borrowed: &String = &String::from("little");
2236 #[cfg(not(target_endian = "little"))]
2237 let borrowed: &String = &String::from("big");
2238 #[cfg(target_endian = "little")]
2239 print!("le ");
2240 #[cfg(not(target_endian = "little"))]
2241 print!("be ");
2242 #[allow(unused_assignments)]
2243 let mut later;
2244 later = value + 1;
2245 println!("{value} {borrowed} {later}");
2246}
2247"#;
2248 let transformed = instrument_rust_source(
2249 "src/main.rs",
2250 attributed_let,
2251 "crate::__supercov_runtime_v1",
2252 )
2253 .unwrap();
2254 let ids = transformed
2255 .manifest
2256 .limitations
2257 .iter()
2258 .filter_map(|limitation| limitation.get("id")?.as_str())
2259 .collect::<BTreeSet<_>>();
2260 assert!(ids.contains("rust-attributed-statement-probes-not-injected"));
2262 assert!(
2263 transformed
2264 .code
2265 .contains("let value = { crate::__supercov_runtime_v1::hit(")
2266 );
2267 assert!(
2268 transformed
2269 .code
2270 .contains("let borrowed: &String = { crate::__supercov_runtime_v1::hit(")
2271 );
2272 assert!(
2273 transformed
2274 .code
2275 .contains("] { crate::__supercov_runtime_v1::hit(")
2276 );
2277 let original = compile_and_run(attributed_let, "cfg-let-original");
2278 let instrumented = compile_and_run(
2279 &format!("{}\n{NOOP_RUNTIME}", transformed.code),
2280 "cfg-let-instrumented",
2281 );
2282 assert_eq!(instrumented.status, original.status);
2283 assert_eq!(instrumented.stdout, original.stdout);
2284 assert_eq!(instrumented.stderr, original.stderr);
2285 }
2286
2287 #[test]
2288 fn rejects_non_crate_local_runtime_paths() {
2289 assert_eq!(
2290 instrument_rust_source("src/lib.rs", "fn okay() {}", "supercov::runtime"),
2291 Err(RustInstrumenterError::InvalidRuntimePath)
2292 );
2293 }
2294
2295 #[test]
2296 fn rejects_invalid_rust_without_partial_obligations() {
2297 assert!(matches!(
2298 build_rust_manifest("src/lib.rs", "fn broken( {\n"),
2299 Err(RustInstrumenterError::Parse(_))
2300 ));
2301 }
2302}