Skip to main content

supercov_engine/
rust_instrumenter.rs

1//! Supercov-owned Rust parsing and obligation discovery.
2//!
3//! The first private frontend deliberately starts from a lossless concrete
4//! syntax tree. LLVM/rustc coverage remains a development oracle; it is not a
5//! product input. Probe insertion and Cargo execution build on this exact
6//! source denominator.
7
8use 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
82/// Report whether rustc will evaluate this node at compile time.
83///
84/// Runtime probes cannot appear anywhere this is true: `condition`, `decision`
85/// and `hit` are not `const fn`, so emitting a call here is not a bad
86/// measurement but a build failure (E0015). bytes-1.12.1 hit exactly that with
87/// `const ITERS: usize = if cfg!(miri) { 100 } else { 1_000 };`.
88///
89/// `ConstArg` is the shared node for enum discriminants, array lengths, const
90/// generic arguments and const parameter defaults, so matching it covers all
91/// four. The remaining case is an array repeat expression, `[value; count]`,
92/// where only the count after the semicolon is const-evaluated.
93/// Report whether the source's doc comments contain a fenced code block.
94///
95/// rustdoc turns fenced blocks in `///`, `//!` and `#[doc]` text into doctest
96/// crates. The scan is line-based and deliberately coarse: a fence inside a
97/// doc comment declares the limitation even when the fence is `ignore`d, which
98/// over-declares the unmeasured surface rather than ever under-declaring it.
99fn 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
128/// Report whether this node sits inside a `GlobalAlloc` implementation.
129///
130/// The probe runtime allocates, so a probe inside `alloc` calls back into
131/// `alloc`, which probes again, until the stack is gone. bytes-1.12.1's
132/// tests/test_bytes_odd_alloc.rs installs a `#[global_allocator]`, and the
133/// instrumented binary died with SIGSEGV before libtest could even list its
134/// tests -- while the uninstrumented one listed them fine.
135///
136/// The general rule this enforces is that nothing the runtime itself calls can
137/// carry a probe, and `#[global_allocator]` is the one way a user crate gets
138/// onto that path. A `GlobalAlloc` impl is skipped whether or not it is the
139/// registered allocator, because the registering `static` may live in another
140/// file: declining a handful of allocator bodies costs almost no exactness,
141/// while instrumenting the live one costs the whole run.
142fn 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
156/// Report whether a probe placed at this node could not run correctly.
157fn 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        // Doctests are compiled and run by rustdoc as separate crates that the
659        // libtest runner never sees, so nothing in them is measured. Under the
660        // honesty rule an unmeasured surface must be declared, not silently
661        // omitted: bytes-1.12.1 has 248 doctests, and a report that says
662        // nothing about them overstates what was checked. Detection scans doc
663        // comments for a code fence, which rustdoc's test collector also keys
664        // on; fences marked `ignore`/`text`/`no_run` still declare, which can
665        // only over-declare the unmeasured surface, never under-declare it.
666        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        // An obligation the probes cannot reach stays in the denominator, but the
673        // gap has to be declared rather than left to read as merely uncovered.
674        // Only a context that actually holds an obligation counts:
675        // `const MAX: usize = 10;` costs nothing and must not raise a limitation.
676        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
807/// Produce a private Rust candidate using only Supercov-owned probe calls.
808///
809/// The caller supplies a collision-free generated crate-local runtime path.
810/// This stage instruments the surfaces whose source transform already has
811/// semantic tests. Remaining branch surfaces stay in the denominator and are
812/// paired with a blocking manifest limitation.
813pub 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    // A probe must never be PREPENDED to a statement that carries outer
835    // attributes. `#[cfg]` selects among adjacent statements, and a bare
836    // `hit(...)` inserted between them survives the strip and changes which
837    // expression is the block's tail: memchr's `is_available` returns bool
838    // from one of two cfg-gated blocks, and the stray probe turned the kept
839    // block into a statement and the probe itself into a `()` tail -- 32
840    // E0308s across the crate. An attributed BLOCK takes the probe inside its
841    // braces, where the same cfg governs both; any other attributed statement
842    // is skipped and declared, mirroring the let-chain limitation.
843    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                    // Outer attributes on an expression statement attach to
875                    // the inner expression in this grammar.
876                    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        // `cannot_carry_probe` covers `const fn` itself, since a node's own
917        // ancestors include the node.
918        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        // Every one of these positions is const-evaluated, so none of them can
1285        // hold a call to the runtime -- `condition`, `decision` and `hit` are
1286        // not `const fn`. Found on bytes-1.12.1, whose test target has
1287        // `const ITERS: usize = if cfg!(miri) { 100 } else { 1_000 };` and
1288        // failed to build with E0015.
1289        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        // The runtime `if` in `scaled` is still instrumented -- declining a
1321        // const initialiser must not decline the whole file.
1322        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        // The runtime allocates, so a probe inside `alloc` re-enters `alloc` and
1344        // recurses until the stack is gone. bytes-1.12.1's
1345        // tests/test_bytes_odd_alloc.rs installs one of these, and the
1346        // instrumented binary died with SIGSEGV before libtest could list a
1347        // single test, while the uninstrumented binary listed them fine.
1348        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        // Nothing inside the allocator may carry a probe...
1381        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        // ...while `classify`, right next to it, is still measured.
1392        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        // rustdoc compiles fenced doc blocks as separate test crates that the
1414        // libtest runner never sees. bytes-1.12.1 has 248 of them; a report
1415        // that says nothing about doctests overstates what was checked.
1416        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        // Doc comments without fences, and fences outside doc comments, are
1431        // not doctests and must not raise the limitation.
1432        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        // memchr's is_available returns bool from one of two cfg-gated blocks.
1447        // A probe PREPENDED to the second block sits between the siblings,
1448        // survives the cfg strip, and becomes the new `()` tail -- 32 E0308s
1449        // across the crate. Attributed blocks take the probe inside their
1450        // braces instead, where the same cfg governs both.
1451        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        // The kept block is still probed -- inside its braces.
1476        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        // An attributed non-block statement is skipped and declared.
1486        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}