Skip to main content

supercov_engine/
ruby_instrumenter.rs

1//! Supercov-owned Ruby obligation discovery.
2//!
3//! Prism (Ruby's own parser) supplies syntax and exact byte ranges. Supercov
4//! owns the denominator: every statement, method, decision and branch
5//! obligation is decided here, ahead of the run, from source alone.
6//!
7//! Alongside the shared [`CoverageManifest`] this module emits a *probe plan*
8//! for the stdlib-only Ruby runtime. The runtime reads Ruby's `Coverage`
9//! module for lines, and proves everything else two ways at once, so that
10//! either interpreter generation can pick its own:
11//!
12//! - **Ruby 3.4+** reads line events only. Asking `Coverage` for `branches`
13//!   and `methods` too made every sample rebuild both tables for every loaded
14//!   file (7 ms per sample with 550 files, five samples per test), which was
15//!   80% of the runtime's cost. Instead, the statement that starts a branch
16//!   body or a method body proves the branch, the method and the decision
17//!   outcome it witnesses (the `implied` map), and what has no such statement
18//!   — an else-less or modifier `if`, a ternary, `&.`, a `case` without
19//!   `else`, an empty body — gets a probe. Probes also prove what `Coverage`
20//!   never sees: the operands of `&&`/`||` for MC/DC, `||=`, loop entry,
21//!   `rescue` flow and a second statement on a line.
22//! - **Ruby 3.3** cannot apply the insertions (it does not cover code compiled
23//!   by a load hook), so it keeps asking `Coverage` for branches and methods
24//!   and proves those obligations by matching its keys in the untouched
25//!   source; the probe-only remainder is declared unmeasured.
26//!
27//! No insertion contains a newline, so line numbers, backtraces and the
28//! stdlib line table stay exact.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use ruby_prism::{
33    AndNode, BeginNode, CallNode, CaseMatchNode, CaseNode, DefNode, ForNode, IfNode, Location,
34    Node, OrNode, RescueModifierNode, RescueNode, StatementsNode, UnlessNode, UntilNode, Visit,
35    WhileNode,
36};
37use serde::{Deserialize, Serialize};
38use serde_json::json;
39use sha2::{Digest, Sha256};
40
41use crate::{
42    coverage_analysis::PointKind,
43    coverage_report::{
44        BranchAlternativeMeta, BranchMeta, CoverageManifest, DecisionMeta, PointMeta,
45    },
46};
47
48pub const RUBY_PROBE_PLAN_VERSION: u32 = 1;
49/// Global the runtime binds its probe receiver to. Chosen to be unpronounceable
50/// in application code.
51pub const RUBY_PROBE_RECEIVER: &str = "$__supercov";
52
53/// Block-taking methods whose block runs once per element (or per count):
54/// the idiomatic Ruby loops. Methods that may call the block zero times on a
55/// non-empty receiver (`cycle`, `loop`, `lazy`) are deliberately absent.
56const ITERATORS: &[&[u8]] = &[
57    b"each",
58    b"each_with_index",
59    b"each_with_object",
60    b"each_pair",
61    b"each_key",
62    b"each_value",
63    b"each_char",
64    b"each_byte",
65    b"each_line",
66    b"each_slice",
67    b"each_cons",
68    b"each_entry",
69    b"each_index",
70    b"reverse_each",
71    b"map",
72    b"collect",
73    b"flat_map",
74    b"collect_concat",
75    b"filter_map",
76    b"select",
77    b"filter",
78    b"reject",
79    b"find",
80    b"detect",
81    b"find_index",
82    b"find_all",
83    b"all?",
84    b"any?",
85    b"none?",
86    b"one?",
87    b"count",
88    b"sum",
89    b"min_by",
90    b"max_by",
91    b"sort_by",
92    b"group_by",
93    b"partition",
94    b"inject",
95    b"reduce",
96    b"take_while",
97    b"drop_while",
98    b"times",
99    b"upto",
100    b"downto",
101    b"step",
102];
103pub const BEGIN_BODY_LIMITATION: &str = "ruby-begin-completion-unmeasured";
104/// A `Ractor.new` block cannot call the probes: a non-main Ractor cannot read
105/// the receiver global, so a probe there raises where the untouched program
106/// ran. Nothing is inserted inside one; what only a probe could prove there
107/// is unmeasured and declared at the block.
108pub const RACTOR_BLOCK_LIMITATION: &str = "ruby-ractor-block-unprobed";
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum RubyInstrumenterError {
112    Parse(String),
113    InvalidRange,
114}
115
116impl std::fmt::Display for RubyInstrumenterError {
117    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Parse(error) => write!(formatter, "Ruby parse failed: {error}"),
120            Self::InvalidRange => write!(formatter, "Ruby parser returned an invalid range"),
121        }
122    }
123}
124
125impl std::error::Error for RubyInstrumenterError {}
126
127/// A source span in one-based lines and zero-based byte columns, the units
128/// Ruby's `Coverage` module reports. Serialized as `[[line, col], [line, col]]`.
129/// What a stdlib key's span names.
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub enum KeyKind {
133    /// A statement list (`then`, `else`, `when`, `in`, a loop body): Ruby
134    /// spans it from its first statement's start to its last statement's
135    /// end, so a probe or wrapper on its first statement becomes part of it.
136    List,
137    /// One expression node: a wrapper around it is a different node.
138    Node,
139    /// A zero-width position at the end of a predicate, which Ruby reports
140    /// for an `if` without a body; it follows anything inserted up to there.
141    Point,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
145#[serde(from = "[[usize; 2]; 2]", into = "[[usize; 2]; 2]")]
146pub struct PlanSpan {
147    pub start: [usize; 2],
148    pub end: [usize; 2],
149}
150
151impl From<[[usize; 2]; 2]> for PlanSpan {
152    fn from(value: [[usize; 2]; 2]) -> Self {
153        Self {
154            start: value[0],
155            end: value[1],
156        }
157    }
158}
159
160impl From<PlanSpan> for [[usize; 2]; 2] {
161    fn from(value: PlanSpan) -> Self {
162        [value.start, value.end]
163    }
164}
165
166/// One text insertion the runtime applies to the original source before
167/// compiling it. Offsets are bytes into the original file; the runtime applies
168/// insertions from the end of the file backwards, so offsets stay valid.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct Edit {
172    pub offset: usize,
173    pub text: String,
174    /// `clause`, `statement`, `opener` or `closer`: how the insertion sits
175    /// relative to the node at its offset, which decides whether keys
176    /// starting or ending there move (see [`Collector::shifted`]).
177    pub rank: String,
178    /// The other end of the range the insertion belongs to: the end of the
179    /// wrapped node or probed statement for an opener or probe, the opener's
180    /// offset for a closer.
181    pub scope: usize,
182}
183
184/// A `Coverage` branch key: the group type (`if`, `case`, `&.`, `while`), the
185/// branch type (`then`, `else`, `when`, `in`, `body`) and the branch span in
186/// post-insertion coordinates.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct StdlibKey {
190    pub group: String,
191    pub branch: String,
192    /// What the key's span names, which decides how insertions at its edges
193    /// move it (see [`Collector::shifted`]).
194    pub kind: KeyKind,
195    /// Span after the plan's insertions are applied.
196    pub span: PlanSpan,
197    /// Span in the untouched source, for interpreters that cannot apply the
198    /// insertions (Ruby 3.3 does not cover code compiled by a load hook).
199    pub unshifted: PlanSpan,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase", deny_unknown_fields)]
204pub struct StdlibDecision {
205    pub id: String,
206    pub value: bool,
207    pub outcome: String,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct BranchKeyPlan {
213    pub key: StdlibKey,
214    /// Obligation IDs proven when this branch executed: alternatives and any
215    /// statement whose first line is shared with an earlier statement.
216    pub hits: Vec<String>,
217    /// A single-condition decision whose vector this branch witnesses.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub decision: Option<StdlibDecision>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase", deny_unknown_fields)]
224pub struct MethodKeyPlan {
225    pub span: PlanSpan,
226    pub unshifted: PlanSpan,
227    pub id: String,
228}
229
230/// What observing one obligation also proves: the branch body it starts, the
231/// method it opens, the single-condition decision outcome it witnesses. Keyed
232/// by the statement (or decision outcome) whose hit implies the rest, so the
233/// runtime pays one hash lookup per first sighting and nothing per execution.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
235#[serde(rename_all = "camelCase", deny_unknown_fields)]
236pub struct ImpliedPlan {
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub hits: Vec<String>,
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub decisions: Vec<StdlibDecision>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase", deny_unknown_fields)]
245pub struct CaseClausePlan {
246    pub key: StdlibKey,
247    pub missed: String,
248    pub selected: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase", deny_unknown_fields)]
253pub struct CaseNoMatchPlan {
254    pub key: StdlibKey,
255    pub matched: String,
256    pub unmatched: String,
257}
258
259/// `case` clauses are tested in order, so a clause was missed exactly when a
260/// later clause (or the implicit else) was selected. The runtime derives that
261/// per phase from the selected counts.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase", deny_unknown_fields)]
264pub struct CasePlan {
265    pub clauses: Vec<CaseClausePlan>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub no_match: Option<CaseNoMatchPlan>,
268}
269
270/// Short-circuit structure of a decision. Leaves are condition indexes.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(untagged)]
273pub enum ConditionTree {
274    Leaf(usize),
275    Node {
276        op: String,
277        items: Vec<ConditionTree>,
278        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
279        negate: bool,
280    },
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase", deny_unknown_fields)]
285pub struct DerivedLogical {
286    pub previous_leaves: Vec<usize>,
287    pub operand_leaves: Vec<usize>,
288    pub short_circuit: String,
289    pub evaluated: String,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase", deny_unknown_fields)]
294pub struct LoopTarget {
295    pub id: String,
296    pub zero: String,
297    pub entered: String,
298    /// `until` enters the body when the predicate is falsy.
299    pub until: bool,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "camelCase", deny_unknown_fields)]
304pub struct HandlerTarget {
305    pub id: String,
306    pub missed: String,
307    pub selected: String,
308}
309
310/// What a probe call reports. The runtime looks the key up and records the
311/// obligations named here.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
314pub enum ProbeTarget {
315    /// `s(k)`: a statement sharing a line with an earlier statement ran.
316    #[serde(rename_all = "camelCase")]
317    Statement { id: String },
318    /// `c(k, i, v)` per condition, `d(k, v)` or `w(k, v)` for the outcome.
319    #[serde(rename_all = "camelCase")]
320    Decision {
321        id: String,
322        width: usize,
323        not: Vec<bool>,
324        tree: ConditionTree,
325        outcome_true: String,
326        outcome_false: String,
327        logical: Vec<DerivedLogical>,
328        #[serde(rename = "loop", skip_serializing_if = "Option::is_none")]
329        loop_: Option<LoopTarget>,
330    },
331    /// `f(k, collection)` at the loop head and `fb(k)` as the first body statement.
332    #[serde(rename_all = "camelCase")]
333    For {
334        id: String,
335        zero: String,
336        entered: String,
337    },
338    /// `l(k, left)` for value-context `&&`/`||`/`||=`/`&&=`.
339    #[serde(rename_all = "camelCase")]
340    Logical {
341        op: String,
342        short_circuit: String,
343        evaluated: String,
344    },
345    /// `pre(k)` before an operator assignment whose target cannot be re-read
346    /// without side effects, `es(k)` as the first thing its right side does:
347    /// arrivals that never started the right side are short-circuits.
348    #[serde(rename_all = "camelCase")]
349    Arrival {
350        short_circuit: String,
351        evaluated: String,
352    },
353    /// `n(k, receiver)` before `&.`: the receiver was nil, or the method was
354    /// called.
355    #[serde(rename_all = "camelCase")]
356    SafeNavigation { nil: String, called: String },
357    /// `hs(k)` where a branch body or method body would start when it has no
358    /// statement to observe: the alternatives (and method) it proves.
359    #[serde(rename_all = "camelCase")]
360    Hits { ids: Vec<String> },
361    /// `ok(k, v)`/`ok0(k)` completion, `h(k, n)` handler entry, `p(k)`
362    /// propagation, `hm(k, v)` rescue-modifier fallback.
363    #[serde(rename_all = "camelCase")]
364    Try {
365        id: String,
366        success: String,
367        raised: String,
368        handlers: Vec<HandlerTarget>,
369    },
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
373#[serde(rename_all = "camelCase", deny_unknown_fields)]
374pub struct RubyFilePlan {
375    pub edits: Vec<Edit>,
376    /// Line -> statement id for statements that own their first line.
377    pub lines: BTreeMap<usize, String>,
378    /// Statement id -> [start, end] byte offsets for line-owned statements,
379    /// so the runtime can probe one whose first line Ruby turns out not to
380    /// count (`begin`, `case` without subject, multi-line literals).
381    pub statement_offsets: BTreeMap<String, [usize; 2]>,
382    pub branches: Vec<BranchKeyPlan>,
383    pub methods: Vec<MethodKeyPlan>,
384    pub cases: Vec<CasePlan>,
385    /// Obligation id -> what its observation implies (Ruby 3.4+ path).
386    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
387    pub implied: BTreeMap<String, ImpliedPlan>,
388    /// This file's share of [`RubyProbePlan::probe_obligations`], so a file
389    /// the runtime fails to instrument can declare exactly its own.
390    #[serde(default)]
391    pub probe_obligations: Vec<String>,
392    /// Byte ranges of `Ractor.new` blocks, where the runtime must not add
393    /// load-time probes either (see [`RACTOR_BLOCK_LIMITATION`]).
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub ractor_blocks: Vec<[usize; 2]>,
396    /// Byte ranges of `refine` blocks. A refinement is active inside its own
397    /// block, so an operator the block refines -- `Hash#[]=` among them --
398    /// is not the one a table probe means; statements there take the
399    /// method-call probe instead.
400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
401    pub refine_blocks: Vec<[usize; 2]>,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "camelCase", deny_unknown_fields)]
406pub struct RubyProbePlan {
407    pub version: u32,
408    pub root: String,
409    pub receiver: String,
410    pub files: BTreeMap<String, RubyFilePlan>,
411    pub probes: BTreeMap<u64, ProbeTarget>,
412    /// See [`RubyProbePlan::probe_obligations`]; stored so the runtime can
413    /// declare them without re-deriving alternative ids.
414    #[serde(default)]
415    pub probe_obligations: Vec<String>,
416}
417
418impl RubyProbePlan {
419    /// Manifest obligations (points, decisions, branches) that only a probe
420    /// can prove: the union of each file's, which already leaves out what
421    /// Ruby 3.3 still proves through Coverage's keys. An interpreter that
422    /// cannot apply the insertions reports them as unmeasured.
423    pub fn probe_obligations(&self) -> Vec<String> {
424        let mut ids = self
425            .files
426            .values()
427            .flat_map(|file| file.probe_obligations.iter().cloned())
428            .collect::<Vec<_>>();
429        ids.sort();
430        ids.dedup();
431        ids
432    }
433}
434
435/// Manifest obligations (points, decisions, branches) that only a probe can
436/// prove.
437fn probe_obligations_of(probes: &BTreeMap<u64, ProbeTarget>) -> Vec<String> {
438    {
439        let mut ids = BTreeSet::new();
440        for target in probes.values() {
441            match target {
442                ProbeTarget::Statement { id } => {
443                    ids.insert(id.clone());
444                }
445                ProbeTarget::Decision {
446                    id,
447                    outcome_true,
448                    logical,
449                    loop_,
450                    ..
451                } => {
452                    ids.insert(id.clone());
453                    ids.insert(branch_of(outcome_true));
454                    for derived in logical {
455                        ids.insert(branch_of(&derived.short_circuit));
456                    }
457                    if let Some(loop_) = loop_ {
458                        ids.insert(loop_.id.clone());
459                    }
460                }
461                ProbeTarget::For { id, .. } => {
462                    ids.insert(id.clone());
463                }
464                ProbeTarget::Logical { short_circuit, .. } => {
465                    ids.insert(branch_of(short_circuit));
466                }
467                ProbeTarget::Arrival { short_circuit, .. } => {
468                    ids.insert(branch_of(short_circuit));
469                }
470                ProbeTarget::SafeNavigation { nil, .. } => {
471                    ids.insert(branch_of(nil));
472                }
473                ProbeTarget::Hits { ids: proven } => {
474                    for id in proven {
475                        ids.insert(obligation_of(id));
476                    }
477                }
478                ProbeTarget::Try { id, handlers, .. } => {
479                    ids.insert(id.clone());
480                    for handler in handlers {
481                        ids.insert(handler.id.clone());
482                    }
483                }
484            }
485        }
486        ids.into_iter().collect()
487    }
488}
489
490/// The probe keys an insertion's text calls: every `<receiver>.<name>(<key>`.
491fn probe_keys_in(text: &str) -> Vec<u64> {
492    let mut keys = Vec::new();
493    let mut rest = text;
494    while let Some(position) = rest.find(RUBY_PROBE_RECEIVER) {
495        rest = &rest[position + RUBY_PROBE_RECEIVER.len()..];
496        let Some(open) = rest.find('(') else { break };
497        let digits: String = rest[open + 1..]
498            .chars()
499            .take_while(char::is_ascii_digit)
500            .collect();
501        if let Ok(key) = digits.parse() {
502            keys.push(key);
503        }
504        rest = &rest[open + 1..];
505    }
506    keys
507}
508
509/// Every id a probe target names, so implications keyed by any of them can
510/// be found when the probe is dropped.
511fn target_ids(target: &ProbeTarget) -> Vec<String> {
512    match target {
513        ProbeTarget::Statement { id } => vec![id.clone()],
514        ProbeTarget::Decision {
515            id,
516            outcome_true,
517            outcome_false,
518            logical,
519            loop_,
520            ..
521        } => {
522            let mut ids = vec![id.clone(), outcome_true.clone(), outcome_false.clone()];
523            for derived in logical {
524                ids.push(derived.short_circuit.clone());
525                ids.push(derived.evaluated.clone());
526            }
527            if let Some(loop_) = loop_ {
528                ids.push(loop_.zero.clone());
529                ids.push(loop_.entered.clone());
530            }
531            ids
532        }
533        ProbeTarget::For { id, zero, entered } => vec![id.clone(), zero.clone(), entered.clone()],
534        ProbeTarget::Logical {
535            short_circuit,
536            evaluated,
537            ..
538        }
539        | ProbeTarget::Arrival {
540            short_circuit,
541            evaluated,
542        } => vec![short_circuit.clone(), evaluated.clone()],
543        ProbeTarget::Try {
544            id,
545            success,
546            raised,
547            handlers,
548        } => {
549            let mut ids = vec![id.clone(), success.clone(), raised.clone()];
550            for handler in handlers {
551                ids.push(handler.id.clone());
552                ids.push(handler.missed.clone());
553                ids.push(handler.selected.clone());
554            }
555            ids
556        }
557        ProbeTarget::SafeNavigation { nil, called } => vec![nil.clone(), called.clone()],
558        ProbeTarget::Hits { ids } => ids.clone(),
559    }
560}
561
562/// `rb:branch:<hash>:alternative` -> `rb:branch:<hash>`; decision outcome
563/// branches are `rb:decision:<hash>:outcome`.
564fn branch_of(alternative: &str) -> String {
565    alternative
566        .rsplit_once(':')
567        .map(|(branch, _)| branch.to_owned())
568        .unwrap_or_else(|| alternative.to_owned())
569}
570
571/// The manifest obligation an id names: a point (`rb:statement:<hash>`,
572/// `rb:function:<hash>`) is its own obligation; an alternative
573/// (`rb:branch:<hash>:selected`, `rb:decision:<hash>:outcome:true`) belongs
574/// to its branch.
575fn obligation_of(id: &str) -> String {
576    if id.matches(':').count() >= 3 {
577        branch_of(id)
578    } else {
579        id.to_owned()
580    }
581}
582
583/// Everything Ruby 3.3 proves by matching `Coverage`'s branch and method
584/// keys. A probe that proves one of these on 3.4+ is not a gap on 3.3.
585fn stdlib_provable(
586    branches: &[BranchKeyPlan],
587    cases: &[CasePlan],
588    methods: &[MethodKeyPlan],
589) -> BTreeSet<String> {
590    let mut ids = BTreeSet::new();
591    for branch in branches {
592        for hit in &branch.hits {
593            ids.insert(obligation_of(hit));
594        }
595        if let Some(decision) = &branch.decision {
596            ids.insert(decision.id.clone());
597            ids.insert(obligation_of(&decision.outcome));
598        }
599    }
600    for case in cases {
601        for clause in &case.clauses {
602            ids.insert(obligation_of(&clause.missed));
603            ids.insert(obligation_of(&clause.selected));
604        }
605        if let Some(no_match) = &case.no_match {
606            ids.insert(obligation_of(&no_match.matched));
607            ids.insert(obligation_of(&no_match.unmatched));
608        }
609    }
610    for method in methods {
611        ids.insert(method.id.clone());
612    }
613    ids
614}
615
616#[derive(Debug, Clone, PartialEq)]
617pub struct RubyFileObligations {
618    pub manifest: CoverageManifest,
619    pub plan: RubyFilePlan,
620    pub probes: BTreeMap<u64, ProbeTarget>,
621}
622
623fn stable_id(file: &str, kind: &str, start: usize, end: usize, suffix: &str) -> String {
624    let mut hash = Sha256::new();
625    for value in [file, kind, &start.to_string(), &end.to_string(), suffix] {
626        hash.update(value.as_bytes());
627        hash.update([0]);
628    }
629    let digest = hash.finalize();
630    let mut encoded = String::with_capacity(24);
631    for byte in &digest[..12] {
632        use std::fmt::Write as _;
633        write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
634    }
635    format!("rb:{kind}:{encoded}")
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
639enum EditRank {
640    Clause,
641    StatementProbe,
642    Opener,
643    Closer,
644}
645
646#[derive(Debug, Clone)]
647struct PendingEdit {
648    offset: usize,
649    rank: EditRank,
650    /// Openers sort by ascending depth (outer first), closers by descending.
651    order: i64,
652    sequence: usize,
653    text: String,
654    scope: usize,
655}
656
657/// How a statement that a stdlib branch key proves on Ruby 3.3 is proven on
658/// 3.4+, where no keys are read.
659#[derive(Debug, Clone)]
660enum KeyProof {
661    /// Like any statement: its own line when it owns one, else a probe.
662    Probe,
663    /// The body of a modifier (`x if c`, `x while c`): it runs exactly when
664    /// this decision outcome or loop alternative is observed, and its line
665    /// belongs to the modifier statement.
666    Implied(String),
667}
668
669#[derive(Debug, Clone)]
670struct KeyStatement {
671    index: usize,
672    proof: KeyProof,
673}
674
675/// The body statements whose execution witnesses each outcome of a
676/// single-condition decision. Both sides present: the decision is derived
677/// from lines; either side missing: the predicate is probed.
678#[derive(Debug, Default)]
679struct DecisionProof {
680    true_statements: Vec<String>,
681    false_statements: Vec<String>,
682}
683
684struct Collector<'a> {
685    file: &'a str,
686    source: &'a [u8],
687    line_starts: Vec<usize>,
688    manifest: CoverageManifest,
689    lines: BTreeMap<usize, String>,
690    statement_offsets: BTreeMap<String, [usize; 2]>,
691    branches: Vec<BranchKeyPlan>,
692    methods: Vec<MethodKeyPlan>,
693    cases: Vec<CasePlan>,
694    implied: BTreeMap<String, ImpliedPlan>,
695    probes: BTreeMap<u64, ProbeTarget>,
696    edits: Vec<PendingEdit>,
697    next_probe: &'a mut u64,
698    point_ids: BTreeSet<String>,
699    decision_ids: BTreeSet<String>,
700    branch_ids: BTreeSet<String>,
701    claimed_lines: BTreeSet<usize>,
702    /// Statement start offsets a stdlib branch key proves on Ruby 3.3 (index
703    /// into `branches`), and how 3.4+ proves them instead.
704    key_statements: BTreeMap<usize, KeyStatement>,
705    /// Start offsets of the body expressions of endless method definitions,
706    /// which take a wrapped probe because `def m = s(k); expr` would end the
707    /// definition at the probe.
708    endless_bodies: std::collections::BTreeSet<usize>,
709    /// Offsets of `&&`/`||` nodes that belong to a decision's tree.
710    tree_logicals: BTreeSet<usize>,
711    /// Statement lists that are expressions in disguise (parentheses, string
712    /// interpolation): their children are not statements in the denominator.
713    expression_lists: BTreeSet<usize>,
714    /// `if`/`unless` nodes that are `case/in` guards, owned by the clause.
715    guard_nodes: BTreeSet<usize>,
716    /// `elsif` nodes already handled through their parent's chain walk.
717    elsif_nodes: BTreeSet<usize>,
718    depth: i64,
719    begin_unmeasured: Vec<(String, usize)>,
720    /// Byte ranges of `Ractor.new` blocks; no insertion may land inside.
721    ractor_blocks: Vec<(usize, usize)>,
722    /// Byte ranges of `refine` blocks (see [`RubyFilePlan::refine_blocks`]).
723    refine_blocks: Vec<[usize; 2]>,
724    error: Option<RubyInstrumenterError>,
725}
726
727impl<'a> Collector<'a> {
728    fn new(file: &'a str, source: &'a [u8], next_probe: &'a mut u64) -> Self {
729        let mut line_starts = vec![0];
730        line_starts.extend(
731            source
732                .iter()
733                .enumerate()
734                .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
735        );
736        Self {
737            file,
738            source,
739            line_starts,
740            manifest: CoverageManifest {
741                unmeasured: Vec::new(),
742                decisions: Vec::new(),
743                points: Vec::new(),
744                branches: Vec::new(),
745                limitations: Vec::new(),
746                scope: None,
747                assertion_sites: Vec::new(),
748            },
749            lines: BTreeMap::new(),
750            statement_offsets: BTreeMap::new(),
751            branches: Vec::new(),
752            methods: Vec::new(),
753            cases: Vec::new(),
754            implied: BTreeMap::new(),
755            probes: BTreeMap::new(),
756            edits: Vec::new(),
757            next_probe,
758            point_ids: BTreeSet::new(),
759            decision_ids: BTreeSet::new(),
760            branch_ids: BTreeSet::new(),
761            claimed_lines: BTreeSet::new(),
762            key_statements: BTreeMap::new(),
763            endless_bodies: std::collections::BTreeSet::new(),
764            tree_logicals: BTreeSet::new(),
765            expression_lists: BTreeSet::new(),
766            guard_nodes: BTreeSet::new(),
767            elsif_nodes: BTreeSet::new(),
768            depth: 0,
769            begin_unmeasured: Vec::new(),
770            ractor_blocks: Vec::new(),
771            refine_blocks: Vec::new(),
772            error: None,
773        }
774    }
775
776    // -- positions ----------------------------------------------------------
777
778    fn line_column(&self, offset: usize) -> (usize, usize) {
779        let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
780        (line_index + 1, offset - self.line_starts[line_index])
781    }
782
783    fn span(&self, start: usize, end: usize) -> PlanSpan {
784        let (start_line, start_column) = self.line_column(start);
785        let (end_line, end_column) = self.line_column(end);
786        PlanSpan {
787            start: [start_line, start_column],
788            end: [end_line, end_column],
789        }
790    }
791
792    fn point_span(&self, offset: usize) -> PlanSpan {
793        let (line, column) = self.line_column(offset);
794        PlanSpan {
795            start: [line, column],
796            end: [line, column],
797        }
798    }
799
800    fn location_span(&self, location: &Location<'_>) -> PlanSpan {
801        self.span(location.start_offset(), location.end_offset())
802    }
803
804    fn node_span(&self, node: &Node<'_>) -> PlanSpan {
805        self.location_span(&node.location())
806    }
807
808    fn text(&self, start: usize, end: usize) -> String {
809        String::from_utf8_lossy(&self.source[start.min(end)..end.min(self.source.len())])
810            .trim()
811            .to_owned()
812    }
813
814    fn statements_span(&self, statements: &Option<StatementsNode<'_>>) -> Option<PlanSpan> {
815        statements
816            .as_ref()
817            .map(|statements| self.location_span(&statements.location()))
818    }
819
820    // -- edits --------------------------------------------------------------
821
822    fn edit(&mut self, offset: usize, rank: EditRank, text: String, scope: usize) {
823        debug_assert!(!text.contains('\n'));
824        let order = match rank {
825            EditRank::Opener => self.depth,
826            EditRank::Closer => -self.depth,
827            _ => 0,
828        };
829        let sequence = self.edits.len();
830        self.edits.push(PendingEdit {
831            offset,
832            rank,
833            order,
834            sequence,
835            text,
836            scope,
837        });
838    }
839
840    fn probe_key(&mut self, target: ProbeTarget) -> u64 {
841        let key = *self.next_probe;
842        *self.next_probe += 1;
843        self.probes.insert(key, target);
844        key
845    }
846
847    fn wrap(&mut self, start: usize, end: usize, opener: String) {
848        self.edit(start, EditRank::Opener, opener, end);
849        self.edit(end, EditRank::Closer, "))".into(), start);
850    }
851
852    // -- manifest helpers ---------------------------------------------------
853
854    fn push_point(
855        &mut self,
856        id: &str,
857        start: usize,
858        end: usize,
859        kind: PointKind,
860        label: Option<String>,
861    ) {
862        let (line, column) = self.line_column(start);
863        self.manifest.points.push(PointMeta {
864            id: id.into(),
865            kind,
866            file: self.file.into(),
867            line,
868            column,
869            source: self.text(start, end),
870            label,
871        });
872    }
873
874    fn branch<const N: usize>(
875        &mut self,
876        start: usize,
877        end: usize,
878        kind: &str,
879        alternatives: [(&str, &str); N],
880    ) -> Option<String> {
881        let id = stable_id(self.file, "branch", start, end, kind);
882        let source = self.text(start, end);
883        self.branch_with_id(id, start, end, kind, source, alternatives)
884    }
885
886    fn branch_with_id<const N: usize>(
887        &mut self,
888        id: String,
889        start: usize,
890        _end: usize,
891        kind: &str,
892        source: String,
893        alternatives: [(&str, &str); N],
894    ) -> Option<String> {
895        if !self.branch_ids.insert(id.clone()) {
896            return None;
897        }
898        let (line, column) = self.line_column(start);
899        self.manifest.branches.push(BranchMeta {
900            id: id.clone(),
901            kind: kind.into(),
902            file: self.file.into(),
903            line,
904            column,
905            source,
906            alternatives: alternatives
907                .into_iter()
908                .map(|(suffix, label)| BranchAlternativeMeta {
909                    id: format!("{id}:{suffix}"),
910                    label: label.into(),
911                })
912                .collect(),
913        });
914        Some(id)
915    }
916
917    fn stdlib(
918        &mut self,
919        group: &str,
920        branch: &str,
921        span: PlanSpan,
922        kind: KeyKind,
923        hits: Vec<String>,
924    ) -> usize {
925        self.branches.push(BranchKeyPlan {
926            key: StdlibKey {
927                group: group.into(),
928                branch: branch.into(),
929                kind,
930                span,
931                unshifted: span,
932            },
933            hits,
934            decision: None,
935        });
936        self.branches.len() - 1
937    }
938
939    // -- statements ---------------------------------------------------------
940
941    fn statements(&mut self, statements: &StatementsNode<'_>) {
942        for statement in statements.body().iter() {
943            self.statement(&statement);
944        }
945    }
946
947    fn statement(&mut self, node: &Node<'_>) {
948        let location = node.location();
949        let (start, end) = (location.start_offset(), location.end_offset());
950        let id = stable_id(self.file, "statement", start, end, "");
951        if !self.point_ids.insert(id.clone()) {
952            return;
953        }
954        self.push_point(&id, start, end, PointKind::Statement, None);
955        let (line, _) = self.line_column(start);
956        if let Some(key) = self.key_statements.get(&start).cloned() {
957            // The body of a modifier or one-line branch: on Ruby 3.3 the
958            // stdlib branch key that proves the branch proves this statement.
959            self.branches[key.index].hits.push(id.clone());
960            if let KeyProof::Implied(by) = key.proof {
961                // A modifier body shares its line with the modifier statement,
962                // so the line proves nothing about it; the decision outcome or
963                // loop entry that runs it does.
964                self.claimed_lines.insert(line);
965                self.implied.entry(by).or_default().hits.push(id);
966                return;
967            }
968        }
969        if self.needs_probe(node) {
970            // No instruction carries this statement's first line: `x = begin`
971            // starts executing inside the begin body.
972            self.claimed_lines.insert(line);
973            let key = self.probe_key(ProbeTarget::Statement { id });
974            self.statement_probe(start, end, key);
975        } else if self.claimed_lines.insert(line) {
976            // Ruby's line table counts the statement's first line; the
977            // offsets let the runtime probe it instead where the interpreter
978            // turns out not to count that line.
979            self.lines.insert(line, id.clone());
980            self.statement_offsets.insert(id, [start, end]);
981        } else {
982            let key = self.probe_key(ProbeTarget::Statement { id });
983            self.statement_probe(start, end, key);
984        }
985    }
986
987    /// `s(k); statement`, or `(s(k); expression)` for the body of an endless
988    /// method definition, which admits exactly one expression.
989    fn statement_probe(&mut self, start: usize, end: usize, key: u64) {
990        if self.endless_bodies.contains(&start) {
991            self.depth += 1;
992            self.edit(
993                start,
994                EditRank::Opener,
995                format!("({RUBY_PROBE_RECEIVER}.s({key}); "),
996                end,
997            );
998            self.edit(end, EditRank::Closer, ")".into(), start);
999            self.depth -= 1;
1000        } else {
1001            self.edit(
1002                start,
1003                EditRank::StatementProbe,
1004                format!("{RUBY_PROBE_RECEIVER}.s({key}); "),
1005                end,
1006            );
1007        }
1008    }
1009
1010    fn needs_probe(&self, node: &Node<'_>) -> bool {
1011        let value = if let Some(write) = node.as_local_variable_write_node() {
1012            Some(write.value())
1013        } else if let Some(write) = node.as_instance_variable_write_node() {
1014            Some(write.value())
1015        } else if let Some(write) = node.as_class_variable_write_node() {
1016            Some(write.value())
1017        } else if let Some(write) = node.as_global_variable_write_node() {
1018            Some(write.value())
1019        } else if let Some(write) = node.as_constant_write_node() {
1020            Some(write.value())
1021        } else {
1022            node.as_multi_write_node().map(|write| write.value())
1023        };
1024        // `x = begin ... end` and `x = (\n ... )` start executing inside the
1025        // value on a later line; Ruby records nothing for the assignment line.
1026        value.is_some_and(|value| {
1027            value
1028                .as_begin_node()
1029                .is_some_and(|begin| begin.begin_keyword_loc().is_some())
1030                || value.as_parentheses_node().is_some()
1031        })
1032    }
1033
1034    /// Register the first statement of a body as proven by a stdlib key.
1035    fn key_body(&mut self, statements: Option<StatementsNode<'_>>, index: usize, proof: KeyProof) {
1036        if let Some(first) = statements.and_then(|statements| statements.body().iter().next()) {
1037            self.key_statements.insert(
1038                first.location().start_offset(),
1039                KeyStatement { index, proof },
1040            );
1041        }
1042    }
1043
1044    /// A clause header (`when 1 then`, `elsif b`, `else`) runs its test on
1045    /// its own line whether or not its body follows, so a body statement
1046    /// starting on that line proves nothing by the line and takes a probe.
1047    fn claim_line_at(&mut self, offset: usize) {
1048        let (line, _) = self.line_column(offset);
1049        self.claimed_lines.insert(line);
1050    }
1051
1052    /// The obligation id of a body's first statement, whose observation
1053    /// (its own line, or a probe when it shares one) witnesses that the body
1054    /// ran. `None` for an empty body.
1055    fn first_statement_id(&self, statements: &Option<StatementsNode<'_>>) -> Option<String> {
1056        let first = statements.as_ref()?.body().iter().next()?;
1057        let location = first.location();
1058        Some(stable_id(
1059            self.file,
1060            "statement",
1061            location.start_offset(),
1062            location.end_offset(),
1063            "",
1064        ))
1065    }
1066
1067    /// Attach what running a body proves to its first statement, or, for a
1068    /// body with no statement, to a `hs(k)` probe inserted where the body
1069    /// would start (`before` and `after` supply the punctuation).
1070    fn body_proves(
1071        &mut self,
1072        statements: &Option<StatementsNode<'_>>,
1073        ids: Vec<String>,
1074        offset: usize,
1075        before: &str,
1076        after: &str,
1077    ) {
1078        match self.first_statement_id(statements) {
1079            Some(first) => self.implied.entry(first).or_default().hits.extend(ids),
1080            None => {
1081                let key = self.probe_key(ProbeTarget::Hits { ids });
1082                // Closer rank: a guard's `d(...)` wrapper closes at the very
1083                // offset an empty `in` body starts, and the probe must follow
1084                // that `))`, not split it.
1085                self.edit(
1086                    offset,
1087                    EditRank::Closer,
1088                    format!("{before}{RUBY_PROBE_RECEIVER}.hs({key}){after}"),
1089                    offset,
1090                );
1091            }
1092        }
1093    }
1094
1095    // -- decisions ----------------------------------------------------------
1096
1097    /// Strip parentheses and `!`/`not`, counting the negations.
1098    fn strip<'n>(&self, node: Node<'n>) -> (Node<'n>, usize) {
1099        let mut current = node;
1100        let mut not = 0;
1101        loop {
1102            if let Some(parens) = current.as_parentheses_node() {
1103                if !parens.is_multiple_statements()
1104                    && let Some(body) = parens.body()
1105                    && let Some(statements) = body.as_statements_node()
1106                    && statements.body().iter().count() == 1
1107                    && let Some(inner) = statements.body().iter().next()
1108                {
1109                    current = inner;
1110                    continue;
1111                }
1112                break;
1113            }
1114            if let Some(call) = current.as_call_node()
1115                && call.name().as_slice() == b"!"
1116                && call.arguments().is_none()
1117                && call.block().is_none()
1118                && let Some(receiver) = call.receiver()
1119            {
1120                not += 1;
1121                current = receiver;
1122                continue;
1123            }
1124            break;
1125        }
1126        (current, not)
1127    }
1128
1129    fn tree(
1130        &mut self,
1131        node: Node<'_>,
1132        leaves: &mut Vec<(usize, usize, usize)>,
1133        logicals: &mut Vec<(String, Vec<Vec<usize>>)>,
1134    ) -> ConditionTree {
1135        let (operand, not) = self.strip(node);
1136        let logical: Option<(&str, Node<'_>, Node<'_>, usize)> =
1137            if let Some(and) = operand.as_and_node() {
1138                Some((
1139                    "and",
1140                    and.left(),
1141                    and.right(),
1142                    operand.location().start_offset(),
1143                ))
1144            } else if let Some(or) = operand.as_or_node() {
1145                Some((
1146                    "or",
1147                    or.left(),
1148                    or.right(),
1149                    operand.location().start_offset(),
1150                ))
1151            } else {
1152                None
1153            };
1154        if let Some((op, left, right, offset)) = logical {
1155            self.tree_logicals.insert(offset);
1156            let first = leaves.len();
1157            let left_tree = self.tree(left, leaves, logicals);
1158            let middle = leaves.len();
1159            let right_tree = self.tree(right, leaves, logicals);
1160            logicals.push((
1161                op.into(),
1162                vec![(first..middle).collect(), (middle..leaves.len()).collect()],
1163            ));
1164            return ConditionTree::Node {
1165                op: op.into(),
1166                items: vec![left_tree, right_tree],
1167                negate: not % 2 == 1,
1168            };
1169        }
1170        let location = operand.location();
1171        leaves.push((location.start_offset(), location.end_offset(), not));
1172        ConditionTree::Leaf(leaves.len() - 1)
1173    }
1174
1175    /// A decision proven by probes: multi-condition predicates, loop
1176    /// predicates and pattern guards. Returns the probe key the outcome
1177    /// wrapper must use.
1178    fn probe_decision(
1179        &mut self,
1180        predicate: Node<'_>,
1181        kind: &str,
1182        loop_: Option<LoopTarget>,
1183        wrapper: &str,
1184    ) -> Option<u64> {
1185        let location = predicate.location();
1186        let (start, end) = (location.start_offset(), location.end_offset());
1187        let id = stable_id(self.file, "decision", start, end, kind);
1188        if !self.decision_ids.insert(id.clone()) {
1189            return None;
1190        }
1191        let mut leaves = Vec::new();
1192        let mut logicals = Vec::new();
1193        let tree = self.tree(predicate, &mut leaves, &mut logicals);
1194        let conditions = leaves
1195            .iter()
1196            .map(|(leaf_start, leaf_end, not)| {
1197                let text = self.text(*leaf_start, *leaf_end);
1198                if not % 2 == 1 {
1199                    format!("!{text}")
1200                } else {
1201                    text
1202                }
1203            })
1204            .collect::<Vec<_>>();
1205        let (line, column) = self.line_column(start);
1206        let source = self.text(start, end);
1207        self.manifest.decisions.push(DecisionMeta {
1208            id: id.clone(),
1209            file: self.file.into(),
1210            line,
1211            column,
1212            source: source.clone(),
1213            conditions,
1214            kind: kind.into(),
1215        });
1216        let outcome_id = format!("{id}:outcome");
1217        self.branch_with_id(
1218            outcome_id.clone(),
1219            start,
1220            end,
1221            kind,
1222            source,
1223            [("true", "true"), ("false", "false")],
1224        );
1225        let mut derived = Vec::new();
1226        for (op, groups) in logicals {
1227            // The logical branch lives on the right operand's range.
1228            let right_leaf = groups[1].first().copied().unwrap_or(0);
1229            let (right_start, right_end, _) = leaves[right_leaf];
1230            if let Some(branch_id) = self.branch(
1231                right_start,
1232                right_end,
1233                &format!("logical-{op}"),
1234                [
1235                    ("short-circuit", "short-circuited"),
1236                    ("evaluated", "right operand evaluated"),
1237                ],
1238            ) {
1239                derived.push(DerivedLogical {
1240                    previous_leaves: groups[0].clone(),
1241                    operand_leaves: groups[1].clone(),
1242                    short_circuit: format!("{branch_id}:short-circuit"),
1243                    evaluated: format!("{branch_id}:evaluated"),
1244                });
1245            }
1246        }
1247        let key = self.probe_key(ProbeTarget::Decision {
1248            id,
1249            width: leaves.len(),
1250            not: leaves.iter().map(|(_, _, not)| not % 2 == 1).collect(),
1251            tree,
1252            outcome_true: format!("{outcome_id}:true"),
1253            outcome_false: format!("{outcome_id}:false"),
1254            logical: derived,
1255            loop_,
1256        });
1257        self.depth += 1;
1258        self.wrap(
1259            start,
1260            end,
1261            format!("{RUBY_PROBE_RECEIVER}.{wrapper}({key}, ("),
1262        );
1263        self.depth += 1;
1264        // A lone condition's value is the outcome; the runtime reads it from
1265        // the outcome probe, so it needs no wrapper of its own.
1266        if leaves.len() > 1 {
1267            for (index, (leaf_start, leaf_end, _)) in leaves.iter().enumerate() {
1268                self.wrap(
1269                    *leaf_start,
1270                    *leaf_end,
1271                    format!("{RUBY_PROBE_RECEIVER}.c({key}, {index}, ("),
1272                );
1273            }
1274        }
1275        self.depth -= 2;
1276        Some(key)
1277    }
1278
1279    /// A single-condition `if`/`unless`/ternary decision: Ruby's `then`/`else`
1280    /// counts already witness both outcomes, so no probe is inserted.
1281    fn stdlib_decision(
1282        &mut self,
1283        predicate: Node<'_>,
1284        kind: &str,
1285        then_index: usize,
1286        else_index: usize,
1287        then_is_true: bool,
1288        proof: DecisionProof,
1289    ) {
1290        let location = predicate.location();
1291        let (start, end) = (location.start_offset(), location.end_offset());
1292        let id = stable_id(self.file, "decision", start, end, kind);
1293        if !self.decision_ids.insert(id.clone()) {
1294            return;
1295        }
1296        let (operand, not) = self.strip(predicate);
1297        let operand_location = operand.location();
1298        let mut condition = self.text(
1299            operand_location.start_offset(),
1300            operand_location.end_offset(),
1301        );
1302        if not % 2 == 1 {
1303            condition = format!("!{condition}");
1304        }
1305        let (line, column) = self.line_column(start);
1306        let source = self.text(start, end);
1307        self.manifest.decisions.push(DecisionMeta {
1308            id: id.clone(),
1309            file: self.file.into(),
1310            line,
1311            column,
1312            source: source.clone(),
1313            conditions: vec![condition],
1314            kind: kind.into(),
1315        });
1316        let outcome_id = format!("{id}:outcome");
1317        self.branch_with_id(
1318            outcome_id.clone(),
1319            start,
1320            end,
1321            kind,
1322            source,
1323            [("true", "true"), ("false", "false")],
1324        );
1325        let (true_index, false_index) = if then_is_true {
1326            (then_index, else_index)
1327        } else {
1328            (else_index, then_index)
1329        };
1330        self.branches[true_index].decision = Some(StdlibDecision {
1331            id: id.clone(),
1332            value: true,
1333            outcome: format!("{outcome_id}:true"),
1334        });
1335        self.branches[false_index].decision = Some(StdlibDecision {
1336            id: id.clone(),
1337            value: false,
1338            outcome: format!("{outcome_id}:false"),
1339        });
1340        // Ruby 3.4+ reads no branch keys. Both arms have a statement: each
1341        // arm's first statement witnesses its outcome. Otherwise the
1342        // predicate is probed -- one call per evaluation, no condition
1343        // wrapper, because a lone condition's value is the outcome.
1344        if !proof.true_statements.is_empty() && !proof.false_statements.is_empty() {
1345            for (statements, value) in [
1346                (&proof.true_statements, true),
1347                (&proof.false_statements, false),
1348            ] {
1349                for statement in statements {
1350                    self.implied
1351                        .entry(statement.clone())
1352                        .or_default()
1353                        .decisions
1354                        .push(StdlibDecision {
1355                            id: id.clone(),
1356                            value,
1357                            outcome: format!("{outcome_id}:{value}"),
1358                        });
1359                }
1360            }
1361        } else {
1362            let key = self.probe_key(ProbeTarget::Decision {
1363                id,
1364                width: 1,
1365                not: vec![not % 2 == 1],
1366                tree: ConditionTree::Leaf(0),
1367                outcome_true: format!("{outcome_id}:true"),
1368                outcome_false: format!("{outcome_id}:false"),
1369                logical: Vec::new(),
1370                loop_: None,
1371            });
1372            self.depth += 1;
1373            self.wrap(start, end, format!("{RUBY_PROBE_RECEIVER}.d({key}, ("));
1374            self.depth -= 1;
1375        }
1376    }
1377
1378    /// Outcome ids of a decision before it is registered, so a modifier body
1379    /// can be tied to the outcome that runs it.
1380    fn decision_outcomes(&self, predicate: &Node<'_>, kind: &str) -> (String, String) {
1381        let location = predicate.location();
1382        let id = stable_id(
1383            self.file,
1384            "decision",
1385            location.start_offset(),
1386            location.end_offset(),
1387            kind,
1388        );
1389        (format!("{id}:outcome:true"), format!("{id}:outcome:false"))
1390    }
1391
1392    /// The first statements of every arm after `subsequent` in an `if`
1393    /// chain, which together witness the false outcome of the arm before
1394    /// them; `None` when an arm is empty or the chain has no final `else`.
1395    fn chain_false_statements(&self, subsequent: Option<Node<'_>>) -> Option<Vec<String>> {
1396        let mut ids = Vec::new();
1397        let mut current = subsequent;
1398        loop {
1399            let node = current?;
1400            if let Some(elsif) = node.as_if_node() {
1401                ids.push(self.first_statement_id(&elsif.statements())?);
1402                current = elsif.subsequent();
1403            } else if let Some(else_node) = node.as_else_node() {
1404                ids.push(self.first_statement_id(&else_node.statements())?);
1405                return Some(ids);
1406            } else {
1407                return None;
1408            }
1409        }
1410    }
1411
1412    /// `Some(truthiness)` for a predicate Ruby folds at compile time: a
1413    /// `true`, `false` or `nil` literal, or a numeric, string or symbol
1414    /// literal, possibly parenthesised. Ruby emits neither a branch nor the
1415    /// dead arm for these.
1416    fn literal_truth(&self, predicate: Node<'_>) -> Option<bool> {
1417        let mut node = predicate;
1418        loop {
1419            let inner = node.as_parentheses_node().and_then(|parens| {
1420                let body = parens.body()?;
1421                let statements = body.as_statements_node()?;
1422                let mut iter = statements.body().iter();
1423                let only = iter.next()?;
1424                iter.next().is_none().then_some(only)
1425            });
1426            match inner {
1427                Some(inner) => node = inner,
1428                None => break,
1429            }
1430        }
1431        if node.as_true_node().is_some()
1432            || node.as_integer_node().is_some()
1433            || node.as_float_node().is_some()
1434            || node.as_rational_node().is_some()
1435            || node.as_imaginary_node().is_some()
1436            || node.as_string_node().is_some()
1437            || node.as_symbol_node().is_some()
1438        {
1439            Some(true)
1440        } else if node.as_false_node().is_some() || node.as_nil_node().is_some() {
1441            Some(false)
1442        } else if let Some(and) = node.as_and_node() {
1443            // Ruby folds `x and false` the same way: the then arm is never
1444            // compiled and reports no branch, while `x` still runs.
1445            match (
1446                self.literal_truth(and.left()),
1447                self.literal_truth(and.right()),
1448            ) {
1449                (Some(false), _) | (_, Some(false)) => Some(false),
1450                (Some(true), Some(true)) => Some(true),
1451                _ => None,
1452            }
1453        } else if let Some(or) = node.as_or_node() {
1454            match (
1455                self.literal_truth(or.left()),
1456                self.literal_truth(or.right()),
1457            ) {
1458                (Some(true), _) | (_, Some(true)) => Some(true),
1459                (Some(false), Some(false)) => Some(false),
1460                _ => None,
1461            }
1462        } else {
1463            None
1464        }
1465    }
1466
1467    fn is_compound(&self, predicate: Node<'_>) -> bool {
1468        let (operand, _) = self.strip(predicate);
1469        operand.as_and_node().is_some() || operand.as_or_node().is_some()
1470    }
1471
1472    /// `predicate` is called twice because Prism nodes are handles that
1473    /// cannot be copied; every call returns the same node.
1474    fn predicate_decision<'n>(
1475        &mut self,
1476        predicate: impl Fn() -> Node<'n>,
1477        kind: &str,
1478        then_index: usize,
1479        else_index: usize,
1480        then_is_true: bool,
1481        proof: DecisionProof,
1482    ) {
1483        if self.is_compound(predicate()) {
1484            self.probe_decision(predicate(), kind, None, "d");
1485        } else {
1486            self.stdlib_decision(
1487                predicate(),
1488                kind,
1489                then_index,
1490                else_index,
1491                then_is_true,
1492                proof,
1493            );
1494        }
1495    }
1496
1497    // -- constructs ---------------------------------------------------------
1498
1499    fn if_node(&mut self, node: &IfNode<'_>, kind: &str) {
1500        let location = node.location();
1501        let node_span = self.location_span(&location);
1502        let then_statements = self.statements_span(&node.statements());
1503        // An `if` without a body gets a zero-width key at its predicate's end.
1504        let (then_span, then_kind) = match then_statements {
1505            Some(span) => (span, KeyKind::List),
1506            None => (
1507                self.point_span(node.predicate().location().end_offset()),
1508                KeyKind::Point,
1509            ),
1510        };
1511        let (else_span, else_kind) = match node.subsequent() {
1512            Some(subsequent) => match subsequent.as_else_node() {
1513                Some(else_node) => match self.statements_span(&else_node.statements()) {
1514                    Some(span) => (span, KeyKind::List),
1515                    None => (self.node_span(&subsequent), KeyKind::Node),
1516                },
1517                None => (self.node_span(&subsequent), KeyKind::Node),
1518            },
1519            None => (node_span, KeyKind::Node),
1520        };
1521        let then_index = self.stdlib("if", "then", then_span, then_kind, Vec::new());
1522        let else_index = self.stdlib("if", "else", else_span, else_kind, Vec::new());
1523        if kind == "ternary" {
1524            // A ternary's arms are expressions, not statements.
1525            if let Some(statements) = node.statements() {
1526                self.expression_lists
1527                    .insert(statements.location().start_offset());
1528            }
1529            if let Some(subsequent) = node.subsequent()
1530                && let Some(else_node) = subsequent.as_else_node()
1531                && let Some(statements) = else_node.statements()
1532            {
1533                self.expression_lists
1534                    .insert(statements.location().start_offset());
1535            }
1536        } else {
1537            // `x if c`: the body precedes the keyword and shares its line
1538            // with the modifier statement, so the true outcome proves it.
1539            let modifier = node.statements().is_some_and(|statements| {
1540                statements.location().start_offset() < node.predicate().location().start_offset()
1541            });
1542            let then_proof = if modifier {
1543                KeyProof::Implied(self.decision_outcomes(&node.predicate(), kind).0)
1544            } else {
1545                KeyProof::Probe
1546            };
1547            self.claim_line_at(node.predicate().location().end_offset());
1548            self.key_body(node.statements(), then_index, then_proof);
1549            if let Some(subsequent) = node.subsequent()
1550                && let Some(else_node) = subsequent.as_else_node()
1551            {
1552                self.claim_line_at(else_node.else_keyword_loc().end_offset());
1553                self.key_body(else_node.statements(), else_index, KeyProof::Probe);
1554            }
1555        }
1556        let proof = if kind == "ternary" {
1557            // Arms are expressions, not statements: nothing to derive from.
1558            DecisionProof::default()
1559        } else {
1560            DecisionProof {
1561                true_statements: self
1562                    .first_statement_id(&node.statements())
1563                    .into_iter()
1564                    .collect(),
1565                false_statements: self
1566                    .chain_false_statements(node.subsequent())
1567                    .unwrap_or_default(),
1568            }
1569        };
1570        self.predicate_decision(
1571            || node.predicate(),
1572            kind,
1573            then_index,
1574            else_index,
1575            true,
1576            proof,
1577        );
1578    }
1579
1580    fn unless_node(&mut self, node: &UnlessNode<'_>) {
1581        let node_span = self.location_span(&node.location());
1582        let then_statements = self.statements_span(&node.statements());
1583        let (then_span, then_kind) = match then_statements {
1584            Some(span) => (span, KeyKind::List),
1585            None => (
1586                self.point_span(node.predicate().location().end_offset()),
1587                KeyKind::Point,
1588            ),
1589        };
1590        let (else_span, else_kind) = match node.else_clause() {
1591            Some(else_node) => match self.statements_span(&else_node.statements()) {
1592                Some(span) => (span, KeyKind::List),
1593                None => (self.location_span(&else_node.location()), KeyKind::Node),
1594            },
1595            None => (node_span, KeyKind::Node),
1596        };
1597        let then_index = self.stdlib("unless", "then", then_span, then_kind, Vec::new());
1598        let else_index = self.stdlib("unless", "else", else_span, else_kind, Vec::new());
1599        let modifier = node.statements().is_some_and(|statements| {
1600            statements.location().start_offset() < node.predicate().location().start_offset()
1601        });
1602        let then_proof = if modifier {
1603            KeyProof::Implied(self.decision_outcomes(&node.predicate(), "unless").1)
1604        } else {
1605            KeyProof::Probe
1606        };
1607        self.claim_line_at(node.predicate().location().end_offset());
1608        self.key_body(node.statements(), then_index, then_proof);
1609        if let Some(else_node) = node.else_clause() {
1610            self.claim_line_at(else_node.else_keyword_loc().end_offset());
1611            self.key_body(else_node.statements(), else_index, KeyProof::Probe);
1612        }
1613        // `unless` runs `then` when the predicate is falsy.
1614        let proof = DecisionProof {
1615            true_statements: node
1616                .else_clause()
1617                .and_then(|else_node| self.first_statement_id(&else_node.statements()))
1618                .into_iter()
1619                .collect(),
1620            false_statements: self
1621                .first_statement_id(&node.statements())
1622                .into_iter()
1623                .collect(),
1624        };
1625        self.predicate_decision(
1626            || node.predicate(),
1627            "unless",
1628            then_index,
1629            else_index,
1630            false,
1631            proof,
1632        );
1633    }
1634
1635    fn loop_node(
1636        &mut self,
1637        location: &Location<'_>,
1638        predicate: Node<'_>,
1639        statements: Option<StatementsNode<'_>>,
1640        begin_modifier: bool,
1641        until: bool,
1642    ) {
1643        let kind = if until { "until" } else { "while" };
1644        let (start, end) = (location.start_offset(), location.end_offset());
1645        let loop_target = if begin_modifier {
1646            // `begin ... end while` always enters the body once.
1647            None
1648        } else {
1649            self.branch(
1650                start,
1651                end,
1652                kind,
1653                [("zero", "zero iterations"), ("entered", "entered")],
1654            )
1655            .map(|id| LoopTarget {
1656                zero: format!("{id}:zero"),
1657                entered: format!("{id}:entered"),
1658                id,
1659                until,
1660            })
1661        };
1662        // The stdlib `body` key proves a same-offset modifier body statement
1663        // on 3.3; on 3.4+ `x while c` runs its body exactly when the loop is
1664        // entered, and any other body statement is proven like a statement.
1665        if let Some(body_span) = self.statements_span(&statements) {
1666            let index = self.stdlib(kind, "body", body_span, KeyKind::List, Vec::new());
1667            let modifier = !begin_modifier
1668                && statements.as_ref().is_some_and(|statements| {
1669                    statements.location().start_offset() < predicate.location().start_offset()
1670                });
1671            let proof = match (&loop_target, modifier) {
1672                (Some(target), true) => KeyProof::Implied(target.entered.clone()),
1673                _ => KeyProof::Probe,
1674            };
1675            self.key_body(statements, index, proof);
1676        }
1677        self.probe_decision(predicate, kind, loop_target, "w");
1678    }
1679
1680    fn for_node(&mut self, node: &ForNode<'_>) {
1681        let location = node.location();
1682        let Some(id) = self.branch(
1683            location.start_offset(),
1684            location.end_offset(),
1685            "for",
1686            [("zero", "zero iterations"), ("entered", "entered")],
1687        ) else {
1688            return;
1689        };
1690        let key = self.probe_key(ProbeTarget::For {
1691            zero: format!("{id}:zero"),
1692            entered: format!("{id}:entered"),
1693            id: id.clone(),
1694        });
1695        let collection = node.collection().location();
1696        self.depth += 1;
1697        self.wrap(
1698            collection.start_offset(),
1699            collection.end_offset(),
1700            format!("{RUBY_PROBE_RECEIVER}.f({key}, ("),
1701        );
1702        self.depth -= 1;
1703        match node
1704            .statements()
1705            .and_then(|statements| statements.body().iter().next())
1706        {
1707            Some(first) => self.edit(
1708                first.location().start_offset(),
1709                EditRank::StatementProbe,
1710                format!("{RUBY_PROBE_RECEIVER}.fb({key}); "),
1711                first.location().end_offset(),
1712            ),
1713            None => {
1714                self.manifest.unmeasured.push(format!("{id}:entered"));
1715                self.manifest.unmeasured.push(format!("{id}:zero"));
1716            }
1717        }
1718    }
1719
1720    /// `items.each { ... }` and friends: Ruby's loops are usually method
1721    /// calls with a block. The receiver is wrapped like a `for` collection
1722    /// and the block body gets the entry probe, so zero-versus-entered is
1723    /// exact for every iterator in [`ITERATORS`].
1724    fn iterator_loop(
1725        &mut self,
1726        node: &CallNode<'_>,
1727        receiver: &Node<'_>,
1728        block: &ruby_prism::BlockNode<'_>,
1729    ) {
1730        let location = node.location();
1731        let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
1732        let Some(id) = self.branch(
1733            location.start_offset(),
1734            location.end_offset(),
1735            &format!("iterator-{}", name.trim_end_matches(['?', '!'])),
1736            [("zero", "zero iterations"), ("entered", "entered")],
1737        ) else {
1738            return;
1739        };
1740        let first = block.body().and_then(|body| {
1741            if let Some(statements) = body.as_statements_node() {
1742                statements.body().iter().next()
1743            } else if let Some(begin) = body.as_begin_node() {
1744                begin
1745                    .statements()
1746                    .and_then(|statements| statements.body().iter().next())
1747            } else {
1748                None
1749            }
1750        });
1751        let Some(first) = first else {
1752            // An empty block never enters; both alternatives stay declared
1753            // but nothing can witness them.
1754            self.manifest.unmeasured.push(format!("{id}:entered"));
1755            self.manifest.unmeasured.push(format!("{id}:zero"));
1756            return;
1757        };
1758        let key = self.probe_key(ProbeTarget::For {
1759            zero: format!("{id}:zero"),
1760            entered: format!("{id}:entered"),
1761            id,
1762        });
1763        let receiver_location = receiver.location();
1764        self.depth += 1;
1765        self.wrap(
1766            receiver_location.start_offset(),
1767            receiver_location.end_offset(),
1768            format!("{RUBY_PROBE_RECEIVER}.f({key}, ("),
1769        );
1770        self.depth -= 1;
1771        self.edit(
1772            first.location().start_offset(),
1773            EditRank::StatementProbe,
1774            format!("{RUBY_PROBE_RECEIVER}.fb({key}); "),
1775            first.location().end_offset(),
1776        );
1777    }
1778
1779    fn case_node(&mut self, node: &CaseNode<'_>) {
1780        let node_span = self.location_span(&node.location());
1781        let (start, end) = (node.location().start_offset(), node.location().end_offset());
1782        // Known up front so that every clause selection also proves "some
1783        // clause matched" for a `case` without `else`.
1784        let no_match_id = node
1785            .else_clause()
1786            .is_none()
1787            .then(|| stable_id(self.file, "branch", start, end, "case-no-match"));
1788        // An explicit `else` is missed whenever a clause before it is selected.
1789        let else_id = node.else_clause().map(|else_node| {
1790            stable_id(
1791                self.file,
1792                "branch",
1793                else_node.location().start_offset(),
1794                else_node.location().end_offset(),
1795                "case-else",
1796            )
1797        });
1798        let mut clauses = Vec::new();
1799        let mut clause_ids = Vec::new();
1800        for (index, condition) in node.conditions().iter().enumerate() {
1801            let Some(when) = condition.as_when_node() else {
1802                continue;
1803            };
1804            let Some(id) = self.branch(
1805                when.location().start_offset(),
1806                when.location().end_offset(),
1807                &format!("case-when-{index}"),
1808                [("missed", "not selected"), ("selected", "selected")],
1809            ) else {
1810                return;
1811            };
1812            let statements = self.statements_span(&when.statements());
1813            let span = statements.unwrap_or_else(|| self.location_span(&when.location()));
1814            let key_index = self.stdlib(
1815                "case",
1816                "when",
1817                span,
1818                if statements.is_some() {
1819                    KeyKind::List
1820                } else {
1821                    KeyKind::Node
1822                },
1823                vec![format!("{id}:selected")],
1824            );
1825            self.key_body(when.statements(), key_index, KeyProof::Probe);
1826            let proven =
1827                self.clause_proof(&id, &clause_ids, no_match_id.as_deref(), else_id.as_deref());
1828            // An empty body gets its probe after `then`, or as a `then` of
1829            // its own after the last condition.
1830            let (offset, before) = match when.then_keyword_loc() {
1831                Some(then) => (then.end_offset(), " "),
1832                None => (
1833                    when.conditions()
1834                        .iter()
1835                        .last()
1836                        .map(|condition| condition.location().end_offset())
1837                        .unwrap_or_else(|| when.location().end_offset()),
1838                    " then ",
1839                ),
1840            };
1841            self.claim_line_at(offset);
1842            self.body_proves(&when.statements(), proven, offset, before, "");
1843            clauses.push(CaseClausePlan {
1844                key: self.branches[key_index].key.clone(),
1845                missed: format!("{id}:missed"),
1846                selected: format!("{id}:selected"),
1847            });
1848            clause_ids.push(id);
1849        }
1850        let no_match = match node.else_clause() {
1851            Some(else_node) => {
1852                let Some(id) = self.branch(
1853                    else_node.location().start_offset(),
1854                    else_node.location().end_offset(),
1855                    "case-else",
1856                    [("missed", "not selected"), ("selected", "selected")],
1857                ) else {
1858                    return;
1859                };
1860                let statements = self.statements_span(&else_node.statements());
1861                let span = statements.unwrap_or_else(|| self.location_span(&else_node.location()));
1862                let key_index = self.stdlib(
1863                    "case",
1864                    "else",
1865                    span,
1866                    if statements.is_some() {
1867                        KeyKind::List
1868                    } else {
1869                        KeyKind::Node
1870                    },
1871                    vec![format!("{id}:selected")],
1872                );
1873                self.key_body(else_node.statements(), key_index, KeyProof::Probe);
1874                let proven = self.clause_proof(&id, &clause_ids, None, None);
1875                self.claim_line_at(else_node.else_keyword_loc().end_offset());
1876                self.body_proves(
1877                    &else_node.statements(),
1878                    proven,
1879                    else_node.else_keyword_loc().end_offset(),
1880                    " ",
1881                    "",
1882                );
1883                clauses.push(CaseClausePlan {
1884                    key: self.branches[key_index].key.clone(),
1885                    missed: format!("{id}:missed"),
1886                    selected: format!("{id}:selected"),
1887                });
1888                None
1889            }
1890            None => self
1891                .branch(
1892                    start,
1893                    end,
1894                    "case-no-match",
1895                    [
1896                        ("matched", "some clause matched"),
1897                        ("unmatched", "no clause matched"),
1898                    ],
1899                )
1900                .map(|id| {
1901                    let key_index = self.stdlib(
1902                        "case",
1903                        "else",
1904                        node_span,
1905                        KeyKind::Node,
1906                        vec![format!("{id}:unmatched")],
1907                    );
1908                    // Ruby 3.4+: an `else` of Supercov's own, whose only
1909                    // statement is the probe, runs exactly when no clause
1910                    // matched and leaves the value nil as before.
1911                    self.no_match_probe(&id, &clause_ids, node.end_keyword_loc().start_offset());
1912                    CaseNoMatchPlan {
1913                        key: self.branches[key_index].key.clone(),
1914                        matched: format!("{id}:matched"),
1915                        unmatched: format!("{id}:unmatched"),
1916                    }
1917                }),
1918        };
1919        self.cases.push(CasePlan { clauses, no_match });
1920    }
1921
1922    /// What selecting a clause proves: itself selected, every earlier clause
1923    /// tested and missed, the explicit `else` missed, and for a `case`
1924    /// without `else` that a clause matched.
1925    fn clause_proof(
1926        &self,
1927        id: &str,
1928        earlier: &[String],
1929        no_match_id: Option<&str>,
1930        else_id: Option<&str>,
1931    ) -> Vec<String> {
1932        let mut ids = vec![format!("{id}:selected")];
1933        ids.extend(earlier.iter().map(|clause| format!("{clause}:missed")));
1934        if let Some(no_match) = no_match_id {
1935            ids.push(format!("{no_match}:matched"));
1936        }
1937        if let Some(else_id) = else_id {
1938            ids.push(format!("{else_id}:missed"));
1939        }
1940        ids
1941    }
1942
1943    fn no_match_probe(&mut self, id: &str, clauses: &[String], end_keyword: usize) {
1944        let mut ids = vec![format!("{id}:unmatched")];
1945        ids.extend(clauses.iter().map(|clause| format!("{clause}:missed")));
1946        let key = self.probe_key(ProbeTarget::Hits { ids });
1947        self.edit(
1948            end_keyword,
1949            EditRank::StatementProbe,
1950            format!("else {RUBY_PROBE_RECEIVER}.hs({key}); "),
1951            end_keyword,
1952        );
1953    }
1954
1955    /// A `case ... in` with no `else` raises NoMatchingPatternError -- or
1956    /// its NoMatchingPatternKeyError subclass -- naming the last pattern it
1957    /// tried. An inserted `else` would swallow that and return nil, so the
1958    /// case is wrapped instead: the original exception is re-raised as it
1959    /// was, and counted as "no pattern matched" only when this case raised
1960    /// it, at its own line, rather than a nested one.
1961    fn no_match_rescue(&mut self, id: &str, clauses: &[String], start: usize, end: usize) {
1962        let mut ids = vec![format!("{id}:unmatched")];
1963        ids.extend(clauses.iter().map(|clause| format!("{clause}:missed")));
1964        let key = self.probe_key(ProbeTarget::Hits { ids });
1965        let (line, _) = self.line_column(start);
1966        self.edit(start, EditRank::Opener, "begin; ".into(), end);
1967        self.edit(
1968            end,
1969            EditRank::Closer,
1970            format!(
1971                "; rescue ::NoMatchingPatternError; {RUBY_PROBE_RECEIVER}.hs({key}) if \
1972                 $!.backtrace_locations&.first&.then {{ |l| l.lineno == {line} && l.path == __FILE__ }}; \
1973                 raise; end"
1974            ),
1975            start,
1976        );
1977    }
1978
1979    fn case_match_node(&mut self, node: &CaseMatchNode<'_>) {
1980        let node_span = self.location_span(&node.location());
1981        let (start, end) = (node.location().start_offset(), node.location().end_offset());
1982        let no_match_id = node
1983            .else_clause()
1984            .is_none()
1985            .then(|| stable_id(self.file, "branch", start, end, "case-no-match"));
1986        // An explicit `else` is missed whenever a clause before it is selected.
1987        let else_id = node.else_clause().map(|else_node| {
1988            stable_id(
1989                self.file,
1990                "branch",
1991                else_node.location().start_offset(),
1992                else_node.location().end_offset(),
1993                "case-else",
1994            )
1995        });
1996        let mut clauses = Vec::new();
1997        let mut clause_ids = Vec::new();
1998        for (index, condition) in node.conditions().iter().enumerate() {
1999            let Some(in_node) = condition.as_in_node() else {
2000                continue;
2001            };
2002            let Some(id) = self.branch(
2003                in_node.location().start_offset(),
2004                in_node.location().end_offset(),
2005                &format!("case-in-{index}"),
2006                [("missed", "not selected"), ("selected", "selected")],
2007            ) else {
2008                return;
2009            };
2010            let statements = self.statements_span(&in_node.statements());
2011            let span = statements.unwrap_or_else(|| self.location_span(&in_node.location()));
2012            let key_index = self.stdlib(
2013                "case",
2014                "in",
2015                span,
2016                if statements.is_some() {
2017                    KeyKind::List
2018                } else {
2019                    KeyKind::Node
2020                },
2021                vec![format!("{id}:selected")],
2022            );
2023            self.key_body(in_node.statements(), key_index, KeyProof::Probe);
2024            let proven =
2025                self.clause_proof(&id, &clause_ids, no_match_id.as_deref(), else_id.as_deref());
2026            let (offset, before) = match in_node.then_loc() {
2027                Some(then) => (then.end_offset(), " "),
2028                None => (in_node.pattern().location().end_offset(), " then "),
2029            };
2030            self.claim_line_at(offset);
2031            self.body_proves(&in_node.statements(), proven, offset, before, "");
2032            clauses.push(CaseClausePlan {
2033                key: self.branches[key_index].key.clone(),
2034                missed: format!("{id}:missed"),
2035                selected: format!("{id}:selected"),
2036            });
2037            clause_ids.push(id);
2038            // A guard is a decision of its own; Ruby reports no branch key
2039            // for it, so it is always probe-driven.
2040            let pattern = in_node.pattern();
2041            if let Some(guard) = pattern.as_if_node() {
2042                self.guard_nodes.insert(pattern.location().start_offset());
2043                self.probe_decision(guard.predicate(), "in-guard", None, "d");
2044            } else if let Some(guard) = pattern.as_unless_node() {
2045                self.guard_nodes.insert(pattern.location().start_offset());
2046                self.probe_decision(guard.predicate(), "in-guard-unless", None, "d");
2047            }
2048        }
2049        let no_match = match node.else_clause() {
2050            Some(else_node) => {
2051                let Some(id) = self.branch(
2052                    else_node.location().start_offset(),
2053                    else_node.location().end_offset(),
2054                    "case-else",
2055                    [("missed", "not selected"), ("selected", "selected")],
2056                ) else {
2057                    return;
2058                };
2059                let statements = self.statements_span(&else_node.statements());
2060                let span = statements.unwrap_or_else(|| self.location_span(&else_node.location()));
2061                let key_index = self.stdlib(
2062                    "case",
2063                    "else",
2064                    span,
2065                    if statements.is_some() {
2066                        KeyKind::List
2067                    } else {
2068                        KeyKind::Node
2069                    },
2070                    vec![format!("{id}:selected")],
2071                );
2072                self.key_body(else_node.statements(), key_index, KeyProof::Probe);
2073                let proven = self.clause_proof(&id, &clause_ids, None, None);
2074                self.claim_line_at(else_node.else_keyword_loc().end_offset());
2075                self.body_proves(
2076                    &else_node.statements(),
2077                    proven,
2078                    else_node.else_keyword_loc().end_offset(),
2079                    " ",
2080                    "",
2081                );
2082                clauses.push(CaseClausePlan {
2083                    key: self.branches[key_index].key.clone(),
2084                    missed: format!("{id}:missed"),
2085                    selected: format!("{id}:selected"),
2086                });
2087                None
2088            }
2089            None => self
2090                .branch(
2091                    start,
2092                    end,
2093                    "case-no-match",
2094                    [
2095                        ("matched", "some pattern matched"),
2096                        ("unmatched", "no pattern matched"),
2097                    ],
2098                )
2099                .map(|id| {
2100                    let key_index = self.stdlib(
2101                        "case",
2102                        "else",
2103                        node_span,
2104                        KeyKind::Node,
2105                        vec![format!("{id}:unmatched")],
2106                    );
2107                    self.no_match_rescue(&id, &clause_ids, start, end);
2108                    CaseNoMatchPlan {
2109                        key: self.branches[key_index].key.clone(),
2110                        matched: format!("{id}:matched"),
2111                        unmatched: format!("{id}:unmatched"),
2112                    }
2113                }),
2114        };
2115        self.cases.push(CasePlan { clauses, no_match });
2116    }
2117
2118    fn safe_navigation(&mut self, node: &CallNode<'_>) {
2119        let location = node.location();
2120        let Some(id) = self.branch(
2121            location.start_offset(),
2122            location.end_offset(),
2123            "safe-navigation",
2124            [("nil", "receiver nil"), ("called", "method called")],
2125        ) else {
2126            return;
2127        };
2128        // Ruby's key runs from the receiver to the closing parenthesis or the
2129        // last argument, and to the message when there are no arguments; a
2130        // block or block argument is never part of it.
2131        let end = match node.arguments() {
2132            Some(arguments) => node
2133                .closing_loc()
2134                .map(|closing| closing.end_offset())
2135                .unwrap_or_else(|| arguments.location().end_offset()),
2136            None => node
2137                .message_loc()
2138                .map(|message| message.end_offset())
2139                .unwrap_or_else(|| location.end_offset()),
2140        };
2141        let (start_line, start_column) = self.line_column(location.start_offset());
2142        let (end_line, end_column) = self.line_column(end);
2143        let span = PlanSpan {
2144            start: [start_line, start_column],
2145            end: [end_line, end_column],
2146        };
2147        self.stdlib(
2148            "&.",
2149            "then",
2150            span,
2151            KeyKind::Node,
2152            vec![format!("{id}:called")],
2153        );
2154        self.stdlib("&.", "else", span, KeyKind::Node, vec![format!("{id}:nil")]);
2155        // Ruby 3.4+: the receiver's value decides, so the receiver is probed.
2156        if let Some(receiver) = node.receiver() {
2157            let key = self.probe_key(ProbeTarget::SafeNavigation {
2158                nil: format!("{id}:nil"),
2159                called: format!("{id}:called"),
2160            });
2161            let receiver = receiver.location();
2162            self.depth += 1;
2163            self.wrap(
2164                receiver.start_offset(),
2165                receiver.end_offset(),
2166                format!("{RUBY_PROBE_RECEIVER}.n({key}, ("),
2167            );
2168            self.depth -= 1;
2169        }
2170    }
2171
2172    fn value_logical(&mut self, op: &str, left: &Node<'_>, node_start: usize, node_end: usize) {
2173        let Some(id) = self.branch(
2174            node_start,
2175            node_end,
2176            &format!("logical-{op}"),
2177            [
2178                ("short-circuit", "short-circuited"),
2179                ("evaluated", "right operand evaluated"),
2180            ],
2181        ) else {
2182            return;
2183        };
2184        let key = self.probe_key(ProbeTarget::Logical {
2185            op: op.into(),
2186            short_circuit: format!("{id}:short-circuit"),
2187            evaluated: format!("{id}:evaluated"),
2188        });
2189        let left = left.location();
2190        self.depth += 1;
2191        self.wrap(
2192            left.start_offset(),
2193            left.end_offset(),
2194            format!("{RUBY_PROBE_RECEIVER}.l({key}, ("),
2195        );
2196        self.depth -= 1;
2197    }
2198
2199    /// `x ||= v` / `x &&= v`. A variable target can be re-read without side
2200    /// effects, so the whole expression becomes `(l(k, x); x ||= v)`; other
2201    /// targets only get the evaluated side.
2202    fn op_assign(
2203        &mut self,
2204        op: &str,
2205        node_start: usize,
2206        node_end: usize,
2207        name: Option<&[u8]>,
2208        value: &Node<'_>,
2209    ) {
2210        let Some(id) = self.branch(
2211            node_start,
2212            node_end,
2213            &format!("{op}-assign"),
2214            [
2215                ("short-circuit", "assignment skipped"),
2216                ("evaluated", "value evaluated and assigned"),
2217            ],
2218        ) else {
2219            return;
2220        };
2221        match name {
2222            Some(name) => {
2223                let key = self.probe_key(ProbeTarget::Logical {
2224                    op: op.into(),
2225                    short_circuit: format!("{id}:short-circuit"),
2226                    evaluated: format!("{id}:evaluated"),
2227                });
2228                let name = String::from_utf8_lossy(name);
2229                self.depth += 1;
2230                self.edit(
2231                    node_start,
2232                    EditRank::Opener,
2233                    format!("({RUBY_PROBE_RECEIVER}.l({key}, {name}); "),
2234                    node_end,
2235                );
2236                self.edit(node_end, EditRank::Closer, ")".into(), node_start);
2237                self.depth -= 1;
2238            }
2239            None => {
2240                // `(pre(k); recv[i] ||= (es(k); v))`: the target is evaluated
2241                // exactly once, as before; arrivals and right-side starts are
2242                // counted per phase and their difference is the skipped side.
2243                let key = self.probe_key(ProbeTarget::Arrival {
2244                    short_circuit: format!("{id}:short-circuit"),
2245                    evaluated: format!("{id}:evaluated"),
2246                });
2247                let value_location = value.location();
2248                self.depth += 1;
2249                self.edit(
2250                    node_start,
2251                    EditRank::Opener,
2252                    format!("({RUBY_PROBE_RECEIVER}.pre({key}); "),
2253                    node_end,
2254                );
2255                self.edit(node_end, EditRank::Closer, ")".into(), node_start);
2256                self.depth += 1;
2257                self.edit(
2258                    value_location.start_offset(),
2259                    EditRank::Opener,
2260                    format!("({RUBY_PROBE_RECEIVER}.es({key}); "),
2261                    value_location.end_offset(),
2262                );
2263                self.edit(
2264                    value_location.end_offset(),
2265                    EditRank::Closer,
2266                    ")".into(),
2267                    value_location.start_offset(),
2268                );
2269                self.depth -= 2;
2270            }
2271        }
2272    }
2273
2274    // -- exception flow -----------------------------------------------------
2275
2276    /// `begin`/`rescue`/`else`/`ensure` in any host: explicit `begin`, a
2277    /// method body, or a `do` block. `close` is where the closing keyword
2278    /// lives, which is where the propagation clause is inserted when the
2279    /// construct has no `else` or `ensure`.
2280    fn begin_node(&mut self, node: &BeginNode<'_>, close: Option<usize>) {
2281        let has_rescue = node.rescue_clause().is_some();
2282        let has_ensure = node.ensure_clause().is_some();
2283        if !has_rescue && !has_ensure {
2284            return;
2285        }
2286        let location = node.location();
2287        let (start, end) = (location.start_offset(), location.end_offset());
2288        let Some(id) = self.branch(
2289            start,
2290            end,
2291            "begin",
2292            [
2293                ("success", "body completed"),
2294                ("raised", "exception raised"),
2295            ],
2296        ) else {
2297            return;
2298        };
2299        let mut handlers = Vec::new();
2300        let mut handler_edits = Vec::new();
2301        let mut rescue = node.rescue_clause();
2302        let mut index = 0;
2303        while let Some(clause) = rescue {
2304            let clause_location = clause.location();
2305            let Some(handler_id) = self.branch(
2306                clause_location.start_offset(),
2307                clause_location.end_offset(),
2308                &format!("rescue-{index}"),
2309                [("missed", "not selected"), ("selected", "selected")],
2310            ) else {
2311                return;
2312            };
2313            handlers.push(HandlerTarget {
2314                missed: format!("{handler_id}:missed"),
2315                selected: format!("{handler_id}:selected"),
2316                id: handler_id,
2317            });
2318            handler_edits.push(self.handler_probe_position(&clause));
2319            rescue = clause.subsequent();
2320            index += 1;
2321        }
2322        let key = self.probe_key(ProbeTarget::Try {
2323            success: format!("{id}:success"),
2324            raised: format!("{id}:raised"),
2325            id: id.clone(),
2326            handlers,
2327        });
2328        for (index, (offset, leading, scope_end)) in handler_edits.into_iter().enumerate() {
2329            let text = if leading {
2330                format!("; {RUBY_PROBE_RECEIVER}.h({key}, {index})")
2331            } else {
2332                format!("{RUBY_PROBE_RECEIVER}.h({key}, {index}); ")
2333            };
2334            self.edit(offset, EditRank::StatementProbe, text, scope_end);
2335        }
2336        // Propagation clause: after every user clause, before else/ensure/end.
2337        let clause_offset = node
2338            .else_clause()
2339            .map(|clause| clause.else_keyword_loc().start_offset())
2340            .or_else(|| {
2341                node.ensure_clause()
2342                    .map(|clause| clause.ensure_keyword_loc().start_offset())
2343            })
2344            .or_else(|| node.end_keyword_loc().map(|loc| loc.start_offset()))
2345            .or(close);
2346        match clause_offset {
2347            Some(offset) => self.edit(
2348                offset,
2349                EditRank::Clause,
2350                format!(
2351                    "rescue Exception => __supercov_e; {RUBY_PROBE_RECEIVER}.p({key}); raise; "
2352                ),
2353                offset,
2354            ),
2355            None => {
2356                self.manifest.unmeasured.push(format!("{id}:raised"));
2357                let (line, _) = self.line_column(start);
2358                self.begin_unmeasured.push((id.clone(), line));
2359            }
2360        }
2361        // Completion: the else clause runs only after a completed body.
2362        if let Some(else_clause) = node.else_clause() {
2363            match else_clause
2364                .statements()
2365                .and_then(|statements| statements.body().iter().next())
2366            {
2367                Some(first) => self.edit(
2368                    first.location().start_offset(),
2369                    EditRank::StatementProbe,
2370                    format!("{RUBY_PROBE_RECEIVER}.ok0({key}); "),
2371                    first.location().end_offset(),
2372                ),
2373                None => self.edit(
2374                    else_clause.else_keyword_loc().end_offset(),
2375                    EditRank::StatementProbe,
2376                    format!(" {RUBY_PROBE_RECEIVER}.ok0({key});"),
2377                    else_clause.else_keyword_loc().end_offset(),
2378                ),
2379            }
2380            return;
2381        }
2382        let last = node
2383            .statements()
2384            .and_then(|statements| statements.body().iter().last());
2385        match last {
2386            Some(last) => {
2387                if !self.completion_probe(last, key) {
2388                    self.manifest.unmeasured.push(format!("{id}:success"));
2389                    let (line, _) = self.line_column(start);
2390                    self.begin_unmeasured.push((id, line));
2391                }
2392            }
2393            None => {
2394                self.manifest.unmeasured.push(format!("{id}:success"));
2395                let (line, _) = self.line_column(start);
2396                self.begin_unmeasured.push((id, line));
2397            }
2398        }
2399    }
2400
2401    /// Where the handler-entry probe goes: before the first body statement,
2402    /// or right after the clause header when the body is empty.
2403    fn handler_probe_position(&self, clause: &RescueNode<'_>) -> (usize, bool, usize) {
2404        if let Some(first) = clause
2405            .statements()
2406            .and_then(|statements| statements.body().iter().next())
2407        {
2408            return (
2409                first.location().start_offset(),
2410                false,
2411                first.location().end_offset(),
2412            );
2413        }
2414        let offset = if let Some(then_keyword) = clause.then_keyword_loc() {
2415            then_keyword.end_offset()
2416        } else if let Some(reference) = clause.reference() {
2417            reference.location().end_offset()
2418        } else if let Some(last) = clause.exceptions().iter().last() {
2419            last.location().end_offset()
2420        } else {
2421            clause.keyword_loc().end_offset()
2422        };
2423        (offset, true, offset)
2424    }
2425
2426    /// True for an expression whose own value can be a jump: a `return`,
2427    /// `break`, `next`, `redo` or `retry`, or an `if`, `unless`, ternary,
2428    /// `case` or nested `begin` with an arm that ends in one. Such an
2429    /// expression may not be wrapped. Ruby rejects the parenthesised form
2430    /// outright when every arm is a jump ("void value expression"), and when
2431    /// only some arms are, passing it as an argument -- which is what a
2432    /// wrapper does -- makes the compiler miscount its stack ("argument stack
2433    /// underflow") for shapes that are hard to predict. Its arms are probed
2434    /// instead. A jump reached through a block, a loop or `&&`/`||` belongs to
2435    /// that construct rather than to this expression's value, and is fine.
2436    fn jump_exposed(&self, node: &Node<'_>) -> bool {
2437        if Self::is_jump(node) {
2438            return true;
2439        }
2440        if let Some(if_node) = node.as_if_node() {
2441            let else_exposed = match if_node.subsequent() {
2442                Some(subsequent) => match subsequent.as_else_node() {
2443                    Some(else_node) => self.arm_jump_exposed(else_node.statements()),
2444                    None => self.jump_exposed(&subsequent),
2445                },
2446                None => false,
2447            };
2448            return else_exposed || self.arm_jump_exposed(if_node.statements());
2449        }
2450        if let Some(unless_node) = node.as_unless_node() {
2451            let else_exposed = match unless_node.else_clause() {
2452                Some(else_node) => self.arm_jump_exposed(else_node.statements()),
2453                None => false,
2454            };
2455            return else_exposed || self.arm_jump_exposed(unless_node.statements());
2456        }
2457        if let Some(begin) = node.as_begin_node() {
2458            if self.arm_jump_exposed(begin.statements())
2459                || begin
2460                    .else_clause()
2461                    .is_some_and(|else_node| self.arm_jump_exposed(else_node.statements()))
2462            {
2463                return true;
2464            }
2465            let mut rescue = begin.rescue_clause();
2466            while let Some(clause) = rescue {
2467                if self.arm_jump_exposed(clause.statements()) {
2468                    return true;
2469                }
2470                rescue = clause.subsequent();
2471            }
2472            return false;
2473        }
2474        if let Some(case_node) = node.as_case_node() {
2475            return case_node
2476                .conditions()
2477                .iter()
2478                .any(|condition| match condition.as_when_node() {
2479                    Some(when_node) => self.arm_jump_exposed(when_node.statements()),
2480                    None => false,
2481                })
2482                || case_node
2483                    .else_clause()
2484                    .is_some_and(|else_node| self.arm_jump_exposed(else_node.statements()));
2485        }
2486        if let Some(case_node) = node.as_case_match_node() {
2487            return case_node
2488                .conditions()
2489                .iter()
2490                .any(|condition| match condition.as_in_node() {
2491                    Some(in_node) => self.arm_jump_exposed(in_node.statements()),
2492                    None => false,
2493                })
2494                || case_node
2495                    .else_clause()
2496                    .is_some_and(|else_node| self.arm_jump_exposed(else_node.statements()));
2497        }
2498        if let Some(parentheses) = node.as_parentheses_node() {
2499            return match parentheses.body() {
2500                Some(body) => match body.as_statements_node() {
2501                    Some(statements) => self.arm_jump_exposed(Some(statements)),
2502                    None => self.jump_exposed(&body),
2503                },
2504                None => false,
2505            };
2506        }
2507        false
2508    }
2509
2510    fn arm_jump_exposed(&self, statements: Option<StatementsNode<'_>>) -> bool {
2511        match statements.and_then(|statements| statements.body().iter().last()) {
2512            Some(last) => self.jump_exposed(&last),
2513            None => false,
2514        }
2515    }
2516
2517    /// The expressions to wrap so the construct's normal completion is
2518    /// observed, or `None` when it cannot be observed at all. A statement that
2519    /// may not be wrapped is replaced by its arms, of which exactly one runs;
2520    /// an arm that is missing (an `if` with no `else`, whose fall-through
2521    /// carries no expression) makes the whole construct unobservable.
2522    fn probe_targets<'n>(&self, node: Node<'n>) -> Option<Vec<Node<'n>>> {
2523        if Self::is_jump(&node) {
2524            return Some(vec![node]);
2525        }
2526        if node.as_multi_write_node().is_some()
2527            || node.as_alias_method_node().is_some()
2528            || node.as_alias_global_variable_node().is_some()
2529            || node.as_undef_node().is_some()
2530        {
2531            return None;
2532        }
2533        if !self.jump_exposed(&node) {
2534            return Some(vec![node]);
2535        }
2536        if let Some(if_node) = node.as_if_node() {
2537            let mut targets = self.arm_targets(if_node.statements())?;
2538            match if_node.subsequent() {
2539                Some(subsequent) => match subsequent.as_else_node() {
2540                    Some(else_node) => targets.extend(self.arm_targets(else_node.statements())?),
2541                    None => targets.extend(self.probe_targets(subsequent)?),
2542                },
2543                None => return None,
2544            }
2545            return Some(targets);
2546        }
2547        if let Some(unless_node) = node.as_unless_node() {
2548            let mut targets = self.arm_targets(unless_node.statements())?;
2549            let else_node = unless_node.else_clause()?;
2550            targets.extend(self.arm_targets(else_node.statements())?);
2551            return Some(targets);
2552        }
2553        if let Some(case_node) = node.as_case_node() {
2554            let mut targets = Vec::new();
2555            for condition in case_node.conditions().iter() {
2556                let when_node = condition.as_when_node()?;
2557                targets.extend(self.arm_targets(when_node.statements())?);
2558            }
2559            targets.extend(self.arm_targets(case_node.else_clause()?.statements())?);
2560            return Some(targets);
2561        }
2562        if let Some(case_node) = node.as_case_match_node() {
2563            let mut targets = Vec::new();
2564            for condition in case_node.conditions().iter() {
2565                let in_node = condition.as_in_node()?;
2566                targets.extend(self.arm_targets(in_node.statements())?);
2567            }
2568            targets.extend(self.arm_targets(case_node.else_clause()?.statements())?);
2569            return Some(targets);
2570        }
2571        if let Some(begin) = node.as_begin_node() {
2572            let mut targets = match begin.else_clause() {
2573                Some(else_node) => self.arm_targets(else_node.statements())?,
2574                None => self.arm_targets(begin.statements())?,
2575            };
2576            let mut rescue = begin.rescue_clause();
2577            while let Some(clause) = rescue {
2578                targets.extend(self.arm_targets(clause.statements())?);
2579                rescue = clause.subsequent();
2580            }
2581            return Some(targets);
2582        }
2583        if let Some(parentheses) = node.as_parentheses_node() {
2584            let body = parentheses.body()?;
2585            return match body.as_statements_node() {
2586                Some(statements) => self.arm_targets(Some(statements)),
2587                None => self.probe_targets(body),
2588            };
2589        }
2590        None
2591    }
2592
2593    fn arm_targets<'n>(&self, statements: Option<StatementsNode<'n>>) -> Option<Vec<Node<'n>>> {
2594        let last = statements.and_then(|statements| statements.body().iter().last())?;
2595        self.probe_targets(last)
2596    }
2597
2598    /// Wrap the body's final statement so its normal completion is observed
2599    /// without changing the value of the construct. Returns false when the
2600    /// statement has no expression form to wrap (see
2601    /// [`Collector::jump_exposed`]).
2602    fn completion_probe(&mut self, last: Node<'_>, key: u64) -> bool {
2603        let Some(targets) = self.probe_targets(last) else {
2604            return false;
2605        };
2606        for target in &targets {
2607            self.wrap_completion(target, key);
2608        }
2609        true
2610    }
2611
2612    /// One expression whose completion proves the construct completed.
2613    fn wrap_completion(&mut self, last: &Node<'_>, key: u64) {
2614        let location = last.location();
2615        let (start, end) = (location.start_offset(), location.end_offset());
2616        let arguments = if let Some(node) = last.as_return_node() {
2617            Some((node.keyword_loc(), node.arguments()))
2618        } else if let Some(node) = last.as_break_node() {
2619            Some((node.keyword_loc(), node.arguments()))
2620        } else {
2621            last.as_next_node()
2622                .map(|node| (node.keyword_loc(), node.arguments()))
2623        };
2624        if let Some((keyword, arguments)) = arguments {
2625            match arguments {
2626                Some(arguments) => {
2627                    let arguments_location = arguments.location();
2628                    let multiple = arguments.arguments().iter().count() > 1
2629                        || arguments
2630                            .arguments()
2631                            .iter()
2632                            .any(|argument| argument.as_splat_node().is_some());
2633                    let (open, close) = if multiple {
2634                        // `return a, b` already returns `[a, b]`.
2635                        (format!("{RUBY_PROBE_RECEIVER}.ok({key}, ["), "])")
2636                    } else {
2637                        (format!("{RUBY_PROBE_RECEIVER}.ok({key}, ("), "))")
2638                    };
2639                    self.depth += 1;
2640                    self.edit(
2641                        arguments_location.start_offset(),
2642                        EditRank::Opener,
2643                        open,
2644                        arguments_location.end_offset(),
2645                    );
2646                    self.edit(
2647                        arguments_location.end_offset(),
2648                        EditRank::Closer,
2649                        close.into(),
2650                        arguments_location.start_offset(),
2651                    );
2652                    self.depth -= 1;
2653                }
2654                None => self.edit(
2655                    keyword.start_offset(),
2656                    EditRank::StatementProbe,
2657                    format!("{RUBY_PROBE_RECEIVER}.ok0({key}); "),
2658                    end,
2659                ),
2660            }
2661            return;
2662        }
2663        if last.as_redo_node().is_some() || last.as_retry_node().is_some() {
2664            self.edit(
2665                start,
2666                EditRank::StatementProbe,
2667                format!("{RUBY_PROBE_RECEIVER}.ok0({key}); "),
2668                end,
2669            );
2670            return;
2671        }
2672        self.depth += 1;
2673        self.wrap(start, end, format!("{RUBY_PROBE_RECEIVER}.ok({key}, ("));
2674        self.depth -= 1;
2675    }
2676
2677    fn rescue_modifier(&mut self, node: &RescueModifierNode<'_>) {
2678        let location = node.location();
2679        let (start, end) = (location.start_offset(), location.end_offset());
2680        let Some(id) = self.branch(
2681            start,
2682            end,
2683            "rescue-modifier",
2684            [
2685                ("success", "expression completed"),
2686                ("raised", "fallback used"),
2687            ],
2688        ) else {
2689            return;
2690        };
2691        let key = self.probe_key(ProbeTarget::Try {
2692            success: format!("{id}:success"),
2693            raised: format!("{id}:raised"),
2694            id,
2695            handlers: Vec::new(),
2696        });
2697        let expression = node.expression();
2698        let fallback_node = node.rescue_expression();
2699        let fallback = fallback_node.location();
2700        self.depth += 1;
2701        if Self::is_jump(&expression) {
2702            // `return x rescue y` has no value to wrap: probe the jump's
2703            // argument or the jump itself, as for a body's final statement.
2704            self.completion_probe(expression, key);
2705        } else {
2706            let expression = expression.location();
2707            self.wrap(
2708                expression.start_offset(),
2709                expression.end_offset(),
2710                format!("{RUBY_PROBE_RECEIVER}.ok({key}, ("),
2711            );
2712        }
2713        if Self::is_jump(&fallback_node) {
2714            // `rescue next` has no value either: `rescue (hm0(k); next)`.
2715            self.edit(
2716                fallback.start_offset(),
2717                EditRank::Opener,
2718                format!("({RUBY_PROBE_RECEIVER}.hm0({key}); "),
2719                fallback.end_offset(),
2720            );
2721            self.edit(
2722                fallback.end_offset(),
2723                EditRank::Closer,
2724                ")".into(),
2725                fallback.start_offset(),
2726            );
2727        } else {
2728            self.wrap(
2729                fallback.start_offset(),
2730                fallback.end_offset(),
2731                format!("{RUBY_PROBE_RECEIVER}.hm({key}, ("),
2732            );
2733        }
2734        self.depth -= 1;
2735    }
2736
2737    /// A statement that leaves its frame or loop without producing a value.
2738    fn is_jump(node: &Node<'_>) -> bool {
2739        node.as_return_node().is_some()
2740            || node.as_break_node().is_some()
2741            || node.as_next_node().is_some()
2742            || node.as_redo_node().is_some()
2743            || node.as_retry_node().is_some()
2744    }
2745
2746    fn def_node(&mut self, node: &DefNode<'_>) {
2747        let location = node.location();
2748        let (start, end) = (location.start_offset(), location.end_offset());
2749        let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
2750        let id = stable_id(self.file, "function", start, end, &name);
2751        if !self.point_ids.insert(id.clone()) {
2752            return;
2753        }
2754        self.push_point(&id, start, end, PointKind::Function, Some(name));
2755        let span = self.location_span(&location);
2756        self.methods.push(MethodKeyPlan {
2757            span,
2758            unshifted: span,
2759            id: id.clone(),
2760        });
2761        // Ruby 3.4+: the body's first statement runs exactly when the method
2762        // is entered; a body with none gets a probe after the signature.
2763        let body_statements = node.body().and_then(|body| {
2764            if let Some(statements) = body.as_statements_node() {
2765                Some(statements)
2766            } else {
2767                body.as_begin_node().and_then(|begin| begin.statements())
2768            }
2769        });
2770        let signature_end = node
2771            .rparen_loc()
2772            .map(|rparen| rparen.end_offset())
2773            .or_else(|| {
2774                node.parameters()
2775                    .map(|parameters| parameters.location().end_offset())
2776            })
2777            .unwrap_or_else(|| node.name_loc().end_offset());
2778        self.body_proves(&body_statements, vec![id], signature_end, "; ", "");
2779        if let Some(body) = node.body()
2780            && let Some(begin) = body.as_begin_node()
2781        {
2782            self.begin_node(&begin, node.end_keyword_loc().map(|loc| loc.start_offset()));
2783        }
2784        if node.equal_loc().is_some()
2785            && let Some(body) = node.body()
2786            && let Some(statements) = body.as_statements_node()
2787        {
2788            for statement in statements.body().iter() {
2789                self.endless_bodies
2790                    .insert(statement.location().start_offset());
2791            }
2792        }
2793    }
2794
2795    // -- finishing ----------------------------------------------------------
2796
2797    /// Column shift the insertions cause on one line, for positions the
2798    /// runtime will read back from Ruby's `Coverage`. Insertions strictly
2799    /// inside a key's line range move whatever follows them. At a key's start,
2800    /// a probe moves an expression (it now follows the probe) but not a
2801    /// statement list whose first statement was probed, since the list still
2802    /// starts where the probe does; a list strictly containing the probed
2803    /// statement moves. An opener moves a key whose node it wraps (the node
2804    /// now sits inside the wrapper) and leaves alone a key whose node contains
2805    /// the wrapped one, since that node now begins with the wrapper. At a key's
2806    /// end, only a closer whose opener lies inside the key extends it: a list
2807    /// includes a wrapper around its last statement, an expression does not
2808    /// include the wrapper around itself. A point key follows everything
2809    /// inserted up to it, closers included.
2810    fn shifted(&self, span: PlanSpan, kind: KeyKind, edits: &[PendingEdit]) -> PlanSpan {
2811        let start_offset = self.line_starts[span.start[0] - 1] + span.start[1];
2812        let end_offset = self.line_starts[span.end[0] - 1] + span.end[1];
2813        let mut start_shift = 0;
2814        let mut end_shift = 0;
2815        for edit in edits {
2816            let (line, _) = self.line_column(edit.offset);
2817            let moves_start = edit.offset < start_offset
2818                || (edit.offset == start_offset
2819                    && match (edit.rank, kind) {
2820                        (_, KeyKind::Point) => true,
2821                        (EditRank::Closer, _) => false,
2822                        (EditRank::Opener, KeyKind::List) => end_offset < edit.scope,
2823                        (EditRank::Opener, KeyKind::Node) => end_offset <= edit.scope,
2824                        (_, KeyKind::List) => end_offset < edit.scope,
2825                        (_, KeyKind::Node) => true,
2826                    });
2827            let moves_end = edit.offset < end_offset
2828                || (edit.offset == end_offset
2829                    && match (edit.rank, kind) {
2830                        (_, KeyKind::Point) => true,
2831                        (EditRank::Closer, KeyKind::List) => edit.scope >= start_offset,
2832                        (EditRank::Closer, KeyKind::Node) => edit.scope > start_offset,
2833                        _ => false,
2834                    });
2835            if line == span.start[0] && moves_start {
2836                start_shift += edit.text.len();
2837            }
2838            if line == span.end[0] && moves_end {
2839                end_shift += edit.text.len();
2840            }
2841        }
2842        PlanSpan {
2843            start: [span.start[0], span.start[1] + start_shift],
2844            end: [span.end[0], span.end[1] + end_shift],
2845        }
2846    }
2847
2848    /// Remove every insertion inside a `Ractor.new` block. What those probes
2849    /// alone would have proven -- and what their observations would have
2850    /// implied -- leaves the denominator, declared at the block; lines stay.
2851    fn drop_ractor_insertions(
2852        &mut self,
2853        pending: Vec<PendingEdit>,
2854        blocks: &[(usize, usize)],
2855    ) -> Vec<PendingEdit> {
2856        let inside = |offset: usize| {
2857            blocks
2858                .iter()
2859                .any(|(start, end)| offset >= *start && offset < *end)
2860        };
2861        let (dropped, kept): (Vec<_>, Vec<_>) =
2862            pending.into_iter().partition(|edit| inside(edit.offset));
2863        let mut keys = BTreeSet::new();
2864        for edit in &dropped {
2865            keys.extend(probe_keys_in(&edit.text));
2866        }
2867        let mut targets = BTreeMap::new();
2868        for key in &keys {
2869            if let Some(target) = self.probes.remove(key) {
2870                targets.insert(*key, target);
2871            }
2872        }
2873        let mut unmeasured = probe_obligations_of(&targets);
2874        for target in targets.values() {
2875            for named in target_ids(target) {
2876                if let Some(implied) = self.implied.get(&named) {
2877                    unmeasured.extend(implied.hits.iter().map(|id| obligation_of(id)));
2878                    unmeasured.extend(implied.decisions.iter().map(|d| d.id.clone()));
2879                }
2880            }
2881        }
2882        unmeasured.sort();
2883        unmeasured.dedup();
2884        self.manifest.unmeasured.extend(unmeasured);
2885        for (start, end) in blocks {
2886            let (line, _) = self.line_column(*start);
2887            let source = self
2888                .text(*start, *end)
2889                .lines()
2890                .next()
2891                .unwrap_or_default()
2892                .to_owned();
2893            self.manifest.limitations.push(limitation(
2894                RACTOR_BLOCK_LIMITATION,
2895                self.file,
2896                line,
2897                &source,
2898                "a Ractor block cannot call Supercov's probes (a non-main Ractor cannot read the probe receiver), so what only a probe could prove inside it is unmeasured; its lines are still counted",
2899            ));
2900        }
2901        kept
2902    }
2903
2904    fn finish(mut self) -> RubyFileObligations {
2905        let mut pending = std::mem::take(&mut self.edits);
2906        pending.sort_by(|left, right| {
2907            left.offset
2908                .cmp(&right.offset)
2909                .then(left.rank.cmp(&right.rank))
2910                .then(left.order.cmp(&right.order))
2911                .then(left.sequence.cmp(&right.sequence))
2912        });
2913        let ractor_blocks = std::mem::take(&mut self.ractor_blocks);
2914        if !ractor_blocks.is_empty() {
2915            pending = self.drop_ractor_insertions(pending, &ractor_blocks);
2916        }
2917        let branches = std::mem::take(&mut self.branches)
2918            .into_iter()
2919            .map(|mut branch| {
2920                branch.key.span = self.shifted(branch.key.span, branch.key.kind, &pending);
2921                branch
2922            })
2923            .collect::<Vec<_>>();
2924        let cases: Vec<CasePlan> = std::mem::take(&mut self.cases)
2925            .into_iter()
2926            .map(|mut case| {
2927                for clause in &mut case.clauses {
2928                    clause.key.span = self.shifted(clause.key.span, clause.key.kind, &pending);
2929                }
2930                if let Some(no_match) = &mut case.no_match {
2931                    no_match.key.span =
2932                        self.shifted(no_match.key.span, no_match.key.kind, &pending);
2933                }
2934                case
2935            })
2936            .collect();
2937        let methods: Vec<MethodKeyPlan> = std::mem::take(&mut self.methods)
2938            .into_iter()
2939            .map(|mut method| {
2940                method.span = self.shifted(method.span, KeyKind::Node, &pending);
2941                method
2942            })
2943            .collect();
2944        let edits = pending
2945            .into_iter()
2946            .map(|edit| Edit {
2947                offset: edit.offset,
2948                text: edit.text,
2949                rank: match edit.rank {
2950                    EditRank::Clause => "clause",
2951                    EditRank::StatementProbe => "statement",
2952                    EditRank::Opener => "opener",
2953                    EditRank::Closer => "closer",
2954                }
2955                .into(),
2956                scope: edit.scope,
2957            })
2958            .collect::<Vec<_>>();
2959        if let Some((id, line)) = self.begin_unmeasured.first() {
2960            let source = self
2961                .manifest
2962                .branches
2963                .iter()
2964                .find(|branch| &branch.id == id)
2965                .map(|branch| branch.source.lines().next().unwrap_or_default().to_owned())
2966                .unwrap_or_default();
2967            self.manifest.limitations.push(limitation(
2968                BEGIN_BODY_LIMITATION,
2969                self.file,
2970                *line,
2971                &source,
2972                "a begin body that is empty, or ends in a statement with no expression form, cannot have its completion observed",
2973            ));
2974        }
2975        self.manifest.unmeasured.sort();
2976        self.manifest.unmeasured.dedup();
2977        RubyFileObligations {
2978            manifest: self.manifest,
2979            plan: RubyFilePlan {
2980                refine_blocks: std::mem::take(&mut self.refine_blocks),
2981                ractor_blocks: ractor_blocks
2982                    .iter()
2983                    .map(|(start, end)| [*start, *end])
2984                    .collect(),
2985                // What only a probe proves on 3.3: the probe-provable set
2986                // minus what 3.3 still proves through Coverage's keys.
2987                probe_obligations: {
2988                    let keyed = stdlib_provable(&branches, &cases, &methods);
2989                    probe_obligations_of(&self.probes)
2990                        .into_iter()
2991                        .filter(|id| !keyed.contains(id))
2992                        .collect()
2993                },
2994                edits,
2995                lines: self.lines,
2996                statement_offsets: self.statement_offsets,
2997                branches,
2998                methods,
2999                cases,
3000                implied: self.implied,
3001            },
3002            probes: self.probes,
3003        }
3004    }
3005}
3006
3007fn limitation(id: &str, file: &str, line: usize, source: &str, reason: &str) -> serde_json::Value {
3008    json!({
3009        "id": id,
3010        "kind": "semantic-safety",
3011        "file": file,
3012        "line": line,
3013        "column": 0,
3014        "source": source,
3015        "reason": reason
3016    })
3017}
3018
3019impl<'pr> Visit<'pr> for Collector<'_> {
3020    fn visit_statements_node(&mut self, node: &StatementsNode<'pr>) {
3021        if !self
3022            .expression_lists
3023            .contains(&node.location().start_offset())
3024        {
3025            self.statements(node);
3026        }
3027        ruby_prism::visit_statements_node(self, node);
3028    }
3029
3030    fn visit_parentheses_node(&mut self, node: &ruby_prism::ParenthesesNode<'pr>) {
3031        if let Some(body) = node.body() {
3032            self.expression_lists.insert(body.location().start_offset());
3033        }
3034        ruby_prism::visit_parentheses_node(self, node);
3035    }
3036
3037    fn visit_embedded_statements_node(&mut self, node: &ruby_prism::EmbeddedStatementsNode<'pr>) {
3038        if let Some(statements) = node.statements() {
3039            self.expression_lists
3040                .insert(statements.location().start_offset());
3041        }
3042        ruby_prism::visit_embedded_statements_node(self, node);
3043    }
3044
3045    fn visit_if_node(&mut self, node: &IfNode<'pr>) {
3046        let offset = node.location().start_offset();
3047        if self.guard_nodes.contains(&offset) {
3048            // A `case/in` guard: Prism hands the guarded pattern over as the
3049            // guard's statements, but a pattern is not a statement. Only the
3050            // predicate holds code.
3051            self.depth += 1;
3052            self.visit(&node.predicate());
3053            self.depth -= 1;
3054            return;
3055        }
3056        if self.elsif_nodes.contains(&offset) {
3057            self.depth += 1;
3058            ruby_prism::visit_if_node(self, node);
3059            self.depth -= 1;
3060            return;
3061        }
3062        if let Some(truthy) = self.literal_truth(node.predicate()) {
3063            // Ruby compiles only the live arm of `if false` / `if true` and
3064            // reports no branch for it; the dead arm is not code that can run.
3065            // A folded `x and false` still evaluates `x`, whose own code is
3066            // measured like any expression.
3067            self.depth += 1;
3068            self.visit(&node.predicate());
3069            if truthy {
3070                if let Some(statements) = node.statements() {
3071                    self.visit_statements_node(&statements);
3072                }
3073            } else if let Some(subsequent) = node.subsequent() {
3074                match subsequent.as_if_node() {
3075                    Some(elsif) => self.visit_if_node(&elsif),
3076                    None => {
3077                        if let Some(else_node) = subsequent.as_else_node()
3078                            && let Some(statements) = else_node.statements()
3079                        {
3080                            self.visit_statements_node(&statements);
3081                        }
3082                    }
3083                }
3084            }
3085            self.depth -= 1;
3086            return;
3087        }
3088        // Ternaries have no `if` keyword; `elsif` is reached through
3089        // `subsequent` and handled by the parent's chain walk below.
3090        let kind = if node.if_keyword_loc().is_none() {
3091            "ternary"
3092        } else {
3093            "if"
3094        };
3095        self.if_node(node, kind);
3096        let mut subsequent = node.subsequent();
3097        while let Some(next) = subsequent {
3098            match next.as_if_node() {
3099                Some(elsif) => {
3100                    self.elsif_nodes.insert(elsif.location().start_offset());
3101                    self.if_node(&elsif, "elsif");
3102                    subsequent = elsif.subsequent();
3103                }
3104                None => break,
3105            }
3106        }
3107        self.depth += 1;
3108        // Children: predicate, statements, then the chain. Elsif nodes are
3109        // visited as children here too, but `if_node` deduplicates by
3110        // decision id.
3111        ruby_prism::visit_if_node(self, node);
3112        self.depth -= 1;
3113    }
3114
3115    fn visit_unless_node(&mut self, node: &ruby_prism::UnlessNode<'pr>) {
3116        if self.guard_nodes.contains(&node.location().start_offset()) {
3117            self.depth += 1;
3118            self.visit(&node.predicate());
3119            self.depth -= 1;
3120            return;
3121        }
3122        if let Some(truthy) = self.literal_truth(node.predicate()) {
3123            self.depth += 1;
3124            self.visit(&node.predicate());
3125            if truthy {
3126                if let Some(else_node) = node.else_clause()
3127                    && let Some(statements) = else_node.statements()
3128                {
3129                    self.visit_statements_node(&statements);
3130                }
3131            } else if let Some(statements) = node.statements() {
3132                self.visit_statements_node(&statements);
3133            }
3134            self.depth -= 1;
3135            return;
3136        }
3137        self.unless_node(node);
3138        self.depth += 1;
3139        ruby_prism::visit_unless_node(self, node);
3140        self.depth -= 1;
3141    }
3142
3143    fn visit_while_node(&mut self, node: &WhileNode<'pr>) {
3144        self.loop_node(
3145            &node.location(),
3146            node.predicate(),
3147            node.statements(),
3148            node.is_begin_modifier(),
3149            false,
3150        );
3151        self.depth += 1;
3152        ruby_prism::visit_while_node(self, node);
3153        self.depth -= 1;
3154    }
3155
3156    fn visit_until_node(&mut self, node: &UntilNode<'pr>) {
3157        self.loop_node(
3158            &node.location(),
3159            node.predicate(),
3160            node.statements(),
3161            node.is_begin_modifier(),
3162            true,
3163        );
3164        self.depth += 1;
3165        ruby_prism::visit_until_node(self, node);
3166        self.depth -= 1;
3167    }
3168
3169    fn visit_for_node(&mut self, node: &ForNode<'pr>) {
3170        self.for_node(node);
3171        self.depth += 1;
3172        ruby_prism::visit_for_node(self, node);
3173        self.depth -= 1;
3174    }
3175
3176    fn visit_case_node(&mut self, node: &CaseNode<'pr>) {
3177        self.case_node(node);
3178        self.depth += 1;
3179        ruby_prism::visit_case_node(self, node);
3180        self.depth -= 1;
3181    }
3182
3183    fn visit_case_match_node(&mut self, node: &CaseMatchNode<'pr>) {
3184        self.case_match_node(node);
3185        self.depth += 1;
3186        ruby_prism::visit_case_match_node(self, node);
3187        self.depth -= 1;
3188    }
3189
3190    fn visit_and_node(&mut self, node: &AndNode<'pr>) {
3191        let location = node.location();
3192        if !self.tree_logicals.contains(&location.start_offset()) {
3193            let left = node.left();
3194            self.value_logical("and", &left, location.start_offset(), location.end_offset());
3195        }
3196        self.depth += 1;
3197        ruby_prism::visit_and_node(self, node);
3198        self.depth -= 1;
3199    }
3200
3201    fn visit_or_node(&mut self, node: &OrNode<'pr>) {
3202        let location = node.location();
3203        if !self.tree_logicals.contains(&location.start_offset()) {
3204            let left = node.left();
3205            self.value_logical("or", &left, location.start_offset(), location.end_offset());
3206        }
3207        self.depth += 1;
3208        ruby_prism::visit_or_node(self, node);
3209        self.depth -= 1;
3210    }
3211
3212    fn visit_call_node(&mut self, node: &CallNode<'pr>) {
3213        if node.name().as_slice() == b"new"
3214            && node.receiver().is_some_and(|receiver| {
3215                receiver
3216                    .as_constant_read_node()
3217                    .is_some_and(|constant| constant.name().as_slice() == b"Ractor")
3218            })
3219            && let Some(block) = node.block()
3220            && block.as_block_node().is_some()
3221        {
3222            let location = block.location();
3223            self.ractor_blocks
3224                .push((location.start_offset(), location.end_offset()));
3225        }
3226        if node.name().as_slice() == b"refine"
3227            && node.receiver().is_none()
3228            && let Some(block) = node.block()
3229            && block.as_block_node().is_some()
3230        {
3231            let location = block.location();
3232            self.refine_blocks
3233                .push([location.start_offset(), location.end_offset()]);
3234        }
3235        if node.is_safe_navigation() {
3236            self.safe_navigation(node);
3237        }
3238        if let Some(block) = node.block()
3239            && let Some(block) = block.as_block_node()
3240            && let Some(receiver) = node.receiver()
3241            && !node.is_safe_navigation()
3242            && ITERATORS.contains(&node.name().as_slice())
3243        {
3244            self.iterator_loop(node, &receiver, &block);
3245        }
3246        self.depth += 1;
3247        ruby_prism::visit_call_node(self, node);
3248        self.depth -= 1;
3249    }
3250
3251    fn visit_local_variable_or_write_node(
3252        &mut self,
3253        node: &ruby_prism::LocalVariableOrWriteNode<'pr>,
3254    ) {
3255        let location = node.location();
3256        let value = node.value();
3257        self.op_assign(
3258            "or",
3259            location.start_offset(),
3260            location.end_offset(),
3261            Some(node.name().as_slice()),
3262            &value,
3263        );
3264        self.depth += 1;
3265        ruby_prism::visit_local_variable_or_write_node(self, node);
3266        self.depth -= 1;
3267    }
3268
3269    fn visit_local_variable_and_write_node(
3270        &mut self,
3271        node: &ruby_prism::LocalVariableAndWriteNode<'pr>,
3272    ) {
3273        let location = node.location();
3274        let value = node.value();
3275        self.op_assign(
3276            "and",
3277            location.start_offset(),
3278            location.end_offset(),
3279            Some(node.name().as_slice()),
3280            &value,
3281        );
3282        self.depth += 1;
3283        ruby_prism::visit_local_variable_and_write_node(self, node);
3284        self.depth -= 1;
3285    }
3286
3287    fn visit_instance_variable_or_write_node(
3288        &mut self,
3289        node: &ruby_prism::InstanceVariableOrWriteNode<'pr>,
3290    ) {
3291        let location = node.location();
3292        let value = node.value();
3293        self.op_assign(
3294            "or",
3295            location.start_offset(),
3296            location.end_offset(),
3297            Some(node.name().as_slice()),
3298            &value,
3299        );
3300        self.depth += 1;
3301        ruby_prism::visit_instance_variable_or_write_node(self, node);
3302        self.depth -= 1;
3303    }
3304
3305    fn visit_instance_variable_and_write_node(
3306        &mut self,
3307        node: &ruby_prism::InstanceVariableAndWriteNode<'pr>,
3308    ) {
3309        let location = node.location();
3310        let value = node.value();
3311        self.op_assign(
3312            "and",
3313            location.start_offset(),
3314            location.end_offset(),
3315            Some(node.name().as_slice()),
3316            &value,
3317        );
3318        self.depth += 1;
3319        ruby_prism::visit_instance_variable_and_write_node(self, node);
3320        self.depth -= 1;
3321    }
3322
3323    fn visit_global_variable_or_write_node(
3324        &mut self,
3325        node: &ruby_prism::GlobalVariableOrWriteNode<'pr>,
3326    ) {
3327        let location = node.location();
3328        let value = node.value();
3329        self.op_assign(
3330            "or",
3331            location.start_offset(),
3332            location.end_offset(),
3333            Some(node.name().as_slice()),
3334            &value,
3335        );
3336        self.depth += 1;
3337        ruby_prism::visit_global_variable_or_write_node(self, node);
3338        self.depth -= 1;
3339    }
3340
3341    fn visit_class_variable_or_write_node(
3342        &mut self,
3343        node: &ruby_prism::ClassVariableOrWriteNode<'pr>,
3344    ) {
3345        let location = node.location();
3346        let value = node.value();
3347        self.op_assign(
3348            "or",
3349            location.start_offset(),
3350            location.end_offset(),
3351            // Reading an uninitialized class variable raises (unlike an
3352            // instance or global variable), so it cannot be re-read up front;
3353            // the arrival form counts the skipped side instead. Found by the
3354            // corpus sweep on railties' `@@extensions ||= {}`.
3355            None,
3356            &value,
3357        );
3358        self.depth += 1;
3359        ruby_prism::visit_class_variable_or_write_node(self, node);
3360        self.depth -= 1;
3361    }
3362
3363    fn visit_call_or_write_node(&mut self, node: &ruby_prism::CallOrWriteNode<'pr>) {
3364        let location = node.location();
3365        let value = node.value();
3366        self.op_assign(
3367            "or",
3368            location.start_offset(),
3369            location.end_offset(),
3370            None,
3371            &value,
3372        );
3373        self.depth += 1;
3374        ruby_prism::visit_call_or_write_node(self, node);
3375        self.depth -= 1;
3376    }
3377
3378    fn visit_call_and_write_node(&mut self, node: &ruby_prism::CallAndWriteNode<'pr>) {
3379        let location = node.location();
3380        let value = node.value();
3381        self.op_assign(
3382            "and",
3383            location.start_offset(),
3384            location.end_offset(),
3385            None,
3386            &value,
3387        );
3388        self.depth += 1;
3389        ruby_prism::visit_call_and_write_node(self, node);
3390        self.depth -= 1;
3391    }
3392
3393    fn visit_index_or_write_node(&mut self, node: &ruby_prism::IndexOrWriteNode<'pr>) {
3394        let location = node.location();
3395        let value = node.value();
3396        self.op_assign(
3397            "or",
3398            location.start_offset(),
3399            location.end_offset(),
3400            None,
3401            &value,
3402        );
3403        self.depth += 1;
3404        ruby_prism::visit_index_or_write_node(self, node);
3405        self.depth -= 1;
3406    }
3407
3408    fn visit_index_and_write_node(&mut self, node: &ruby_prism::IndexAndWriteNode<'pr>) {
3409        let location = node.location();
3410        let value = node.value();
3411        self.op_assign(
3412            "and",
3413            location.start_offset(),
3414            location.end_offset(),
3415            None,
3416            &value,
3417        );
3418        self.depth += 1;
3419        ruby_prism::visit_index_and_write_node(self, node);
3420        self.depth -= 1;
3421    }
3422
3423    fn visit_constant_or_write_node(&mut self, node: &ruby_prism::ConstantOrWriteNode<'pr>) {
3424        let location = node.location();
3425        let value = node.value();
3426        self.op_assign(
3427            "or",
3428            location.start_offset(),
3429            location.end_offset(),
3430            None,
3431            &value,
3432        );
3433        self.depth += 1;
3434        ruby_prism::visit_constant_or_write_node(self, node);
3435        self.depth -= 1;
3436    }
3437
3438    fn visit_def_node(&mut self, node: &DefNode<'pr>) {
3439        self.def_node(node);
3440        self.depth += 1;
3441        ruby_prism::visit_def_node(self, node);
3442        self.depth -= 1;
3443    }
3444
3445    fn visit_begin_node(&mut self, node: &BeginNode<'pr>) {
3446        // Explicit `begin ... end`. Method and block bodies reach `begin_node`
3447        // through their hosts, which know the closing keyword; the branch id
3448        // dedupes the second visit.
3449        if node.begin_keyword_loc().is_some() {
3450            self.begin_node(node, None);
3451        }
3452        self.depth += 1;
3453        ruby_prism::visit_begin_node(self, node);
3454        self.depth -= 1;
3455    }
3456
3457    fn visit_block_node(&mut self, node: &ruby_prism::BlockNode<'pr>) {
3458        if let Some(body) = node.body()
3459            && let Some(begin) = body.as_begin_node()
3460        {
3461            self.begin_node(&begin, Some(node.closing_loc().start_offset()));
3462        }
3463        self.depth += 1;
3464        ruby_prism::visit_block_node(self, node);
3465        self.depth -= 1;
3466    }
3467
3468    fn visit_lambda_node(&mut self, node: &ruby_prism::LambdaNode<'pr>) {
3469        if let Some(body) = node.body()
3470            && let Some(begin) = body.as_begin_node()
3471        {
3472            self.begin_node(&begin, Some(node.closing_loc().start_offset()));
3473        }
3474        self.depth += 1;
3475        ruby_prism::visit_lambda_node(self, node);
3476        self.depth -= 1;
3477    }
3478
3479    fn visit_rescue_modifier_node(&mut self, node: &RescueModifierNode<'pr>) {
3480        self.rescue_modifier(node);
3481        self.depth += 1;
3482        ruby_prism::visit_rescue_modifier_node(self, node);
3483        self.depth -= 1;
3484    }
3485}
3486
3487/// Build the complete obligation manifest and probe plan for one Ruby file.
3488/// `next_probe` numbers probes uniquely across the whole project.
3489pub fn build_ruby_obligations(
3490    file: &str,
3491    source: &[u8],
3492    next_probe: &mut u64,
3493) -> Result<RubyFileObligations, RubyInstrumenterError> {
3494    let result = ruby_prism::parse(source);
3495    let errors = result
3496        .errors()
3497        .map(|error| error.message().to_owned())
3498        .collect::<Vec<_>>();
3499    if !errors.is_empty() {
3500        return Err(RubyInstrumenterError::Parse(errors.join("; ")));
3501    }
3502    let mut collector = Collector::new(file, source, next_probe);
3503    collector.visit(&result.node());
3504    if let Some(error) = collector.error.take() {
3505        return Err(error);
3506    }
3507    Ok(collector.finish())
3508}
3509
3510/// Apply a plan's edits to the original source the way the runtime does.
3511pub fn apply_edits(source: &[u8], edits: &[Edit]) -> Vec<u8> {
3512    let mut output =
3513        Vec::with_capacity(source.len() + edits.iter().map(|e| e.text.len()).sum::<usize>());
3514    let mut cursor = 0;
3515    for edit in edits {
3516        output.extend_from_slice(&source[cursor..edit.offset]);
3517        output.extend_from_slice(edit.text.as_bytes());
3518        cursor = edit.offset;
3519    }
3520    output.extend_from_slice(&source[cursor..]);
3521    output
3522}
3523
3524#[cfg(test)]
3525mod tests {
3526    use super::*;
3527
3528    const SOURCE: &str = r#"class Shapes
3529  def classify(a, b, c)
3530    if a && (b || c)
3531      :yes
3532    elsif a
3533      :half
3534    else
3535      :no
3536    end
3537  end
3538
3539  def loops(items, flag)
3540    total = 0
3541    items.each { |i| total += i if i > 2 && flag }
3542    while total > 100
3543      total -= 50
3544    end
3545    for x in items do total += x end
3546    total
3547  end
3548
3549  def logical(a, b)
3550    x = a || b
3551    @cache ||= {}
3552    @cache[a] ||= b
3553    y = a ? 1 : 2; z = a&.size
3554    [x, y, z]
3555  end
3556
3557  def guarded(s)
3558    Integer(s)
3559  rescue ArgumentError
3560    -1
3561  ensure
3562    @done = true
3563  end
3564
3565  def cases(v)
3566    case v
3567    when 0 then :zero
3568    else :other
3569    end
3570    v.to_s rescue "bad"
3571  end
3572end
3573"#;
3574
3575    #[test]
3576    fn discovers_obligations_with_stable_ids_and_newline_free_edits() {
3577        let mut probe = 0;
3578        let first = build_ruby_obligations("lib/shapes.rb", SOURCE.as_bytes(), &mut probe).unwrap();
3579        let mut probe = 0;
3580        let second =
3581            build_ruby_obligations("lib/shapes.rb", SOURCE.as_bytes(), &mut probe).unwrap();
3582        assert_eq!(first, second);
3583        let manifest = &first.manifest;
3584        let functions = manifest
3585            .points
3586            .iter()
3587            .filter(|point| point.kind == PointKind::Function)
3588            .map(|point| point.label.clone().unwrap())
3589            .collect::<Vec<_>>();
3590        assert_eq!(
3591            functions,
3592            ["classify", "loops", "logical", "guarded", "cases"]
3593        );
3594        let compound = manifest
3595            .decisions
3596            .iter()
3597            .find(|decision| decision.source == "a && (b || c)")
3598            .unwrap();
3599        assert_eq!(compound.conditions, ["a", "b", "c"]);
3600        assert_eq!(compound.kind, "if");
3601        assert!(
3602            manifest
3603                .decisions
3604                .iter()
3605                .any(|d| d.kind == "elsif" && d.conditions == ["a"])
3606        );
3607        assert!(manifest.decisions.iter().any(|d| d.kind == "ternary"));
3608        assert!(manifest.decisions.iter().any(|d| d.kind == "while"));
3609        for kind in [
3610            "for",
3611            "logical-or",
3612            "or-assign",
3613            "safe-navigation",
3614            "begin",
3615            "rescue-0",
3616            "case-when-0",
3617            "case-else",
3618            "rescue-modifier",
3619        ] {
3620            assert!(
3621                manifest.branches.iter().any(|branch| branch.kind == kind),
3622                "missing branch kind {kind}"
3623            );
3624        }
3625        for edit in &first.plan.edits {
3626            assert!(!edit.text.contains('\n'));
3627        }
3628        assert!(
3629            first
3630                .plan
3631                .edits
3632                .windows(2)
3633                .all(|pair| pair[0].offset <= pair[1].offset)
3634        );
3635        // `@cache[a] ||= b` is measured through arrival and right-side
3636        // probes; nothing is unmeasured.
3637        assert!(manifest.unmeasured.is_empty());
3638        assert!(manifest.limitations.is_empty());
3639    }
3640
3641    #[test]
3642    fn transformed_source_keeps_line_count_and_carries_probes() {
3643        let mut probe = 0;
3644        let obligations =
3645            build_ruby_obligations("lib/shapes.rb", SOURCE.as_bytes(), &mut probe).unwrap();
3646        let transformed =
3647            String::from_utf8(apply_edits(SOURCE.as_bytes(), &obligations.plan.edits)).unwrap();
3648        assert_eq!(transformed.lines().count(), SOURCE.lines().count());
3649        assert!(
3650            transformed.contains("if $__supercov.d(0, ($__supercov.c(0, 0, (a)) && ($__supercov.c(0, 1, (b)) || $__supercov.c(0, 2, (c)))))"),
3651            "{transformed}"
3652        );
3653        assert!(transformed.contains("while $__supercov.w("));
3654        assert!(transformed.contains("for x in $__supercov.f("));
3655        assert!(transformed.contains("do $__supercov.fb("));
3656        assert!(transformed.contains("x = $__supercov.l("));
3657        assert!(transformed.contains("($__supercov.pre("));
3658        assert!(transformed.contains("||= ($__supercov.es("));
3659        assert!(transformed.contains("rescue Exception => __supercov_e; $__supercov.p("));
3660        assert!(transformed.contains("$__supercov.h("));
3661        assert!(transformed.contains("$__supercov.ok("));
3662        assert!(transformed.contains("rescue $__supercov.hm("));
3663        // The second statement on the `y = ...; z = ...` line gets a probe.
3664        assert!(transformed.contains("; $__supercov.s("));
3665        // Same-offset modifier bodies are proven by the stdlib branch key.
3666        assert!(
3667            obligations
3668                .plan
3669                .branches
3670                .iter()
3671                .any(|branch| !branch.hits.is_empty() && branch.key.group == "if")
3672        );
3673    }
3674
3675    #[test]
3676    fn jumps_and_endless_bodies_take_wrapped_probes_and_literal_predicates_fold() {
3677        let source = "def inc(x) = x + 1\n\
3678                      [1].each { |v| y = Integer(v) rescue next }\n\
3679                      if false\n  dead\nelse\n  live\nend\n";
3680        let mut probe = 0;
3681        let obligations =
3682            build_ruby_obligations("lib/x.rb", source.as_bytes(), &mut probe).unwrap();
3683        let transformed =
3684            String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
3685        assert!(
3686            transformed.contains("def inc(x) = ($__supercov.s("),
3687            "{transformed}"
3688        );
3689        assert!(
3690            transformed.contains("rescue ($__supercov.hm0("),
3691            "{transformed}"
3692        );
3693        // `if false` has no branch and its dead arm no statements.
3694        assert!(
3695            obligations
3696                .plan
3697                .branches
3698                .iter()
3699                .all(|branch| branch.key.group != "if")
3700        );
3701        assert!(
3702            obligations
3703                .manifest
3704                .points
3705                .iter()
3706                .all(|point| point.source != "dead")
3707        );
3708        assert!(
3709            obligations
3710                .manifest
3711                .points
3712                .iter()
3713                .any(|point| point.source == "live")
3714        );
3715    }
3716
3717    #[test]
3718    fn void_valued_last_statements_are_probed_arm_by_arm() {
3719        // Wrapping `if ... return ... else return ... end` in a value context
3720        // is a syntax error; each arm carries the completion probe instead.
3721        let source =
3722            "def m(c)\n  if c\n    return 1\n  else\n    return 2\n  end\nrescue\n  nil\nend\n";
3723        let mut probe = 0;
3724        let obligations =
3725            build_ruby_obligations("lib/v.rb", source.as_bytes(), &mut probe).unwrap();
3726        let transformed =
3727            String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
3728        assert_eq!(
3729            transformed.matches("$__supercov.ok(").count(),
3730            2,
3731            "{transformed}"
3732        );
3733        assert!(
3734            transformed.contains("return $__supercov.ok("),
3735            "{transformed}"
3736        );
3737        assert!(
3738            !obligations
3739                .manifest
3740                .unmeasured
3741                .iter()
3742                .any(|id| id.ends_with(":success")),
3743            "{:?}",
3744            obligations.manifest.unmeasured
3745        );
3746        assert!(!obligations.plan.probe_obligations.is_empty());
3747    }
3748
3749    #[test]
3750    fn expressions_that_can_return_are_never_wrapped() {
3751        // `ok(k, (if a then return b end))` passes an expression that can jump
3752        // as an argument, which makes Ruby's compiler miscount its stack.
3753        let source = "def m(a, d)\n  begin\n    if a\n      return d\n    end\n  ensure\n    unlock\n  end\n  d\nend\n";
3754        let mut probe = 0;
3755        let obligations =
3756            build_ruby_obligations("lib/e.rb", source.as_bytes(), &mut probe).unwrap();
3757        let transformed =
3758            String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
3759        assert!(!transformed.contains("$__supercov.ok"), "{transformed}");
3760        assert!(
3761            obligations
3762                .manifest
3763                .unmeasured
3764                .iter()
3765                .any(|id| id.ends_with(":success")),
3766            "the begin's completion is declared instead"
3767        );
3768        // With both arms present the arms carry the probe and nothing is lost.
3769        let both = "def m(a, d)\n  begin\n    if a\n      return d\n    else\n      d + 1\n    end\n  ensure\n    unlock\n  end\nend\n";
3770        let mut probe = 0;
3771        let obligations = build_ruby_obligations("lib/f.rb", both.as_bytes(), &mut probe).unwrap();
3772        let transformed =
3773            String::from_utf8(apply_edits(both.as_bytes(), &obligations.plan.edits)).unwrap();
3774        assert!(
3775            transformed.contains("return $__supercov.ok("),
3776            "{transformed}"
3777        );
3778        assert!(
3779            !obligations
3780                .manifest
3781                .unmeasured
3782                .iter()
3783                .any(|id| id.ends_with(":success")),
3784            "{:?}",
3785            obligations.manifest.unmeasured
3786        );
3787    }
3788
3789    #[test]
3790    fn stdlib_keys_shift_with_insertions_on_their_line() {
3791        let source = "def f(a, b)\n  x = 1 if a && b\nend\n";
3792        let mut probe = 0;
3793        let obligations = build_ruby_obligations("m.rb", source.as_bytes(), &mut probe).unwrap();
3794        let then_key = obligations
3795            .plan
3796            .branches
3797            .iter()
3798            .find(|branch| branch.key.branch == "then")
3799            .unwrap();
3800        // `x = 1` starts at column 2 and nothing is inserted before it.
3801        assert_eq!(then_key.key.span.start, [2, 2]);
3802        // Its end (column 7) is untouched too: insertions land in the
3803        // predicate, which comes after the body on this line.
3804        assert_eq!(then_key.key.span.end, [2, 7]);
3805        let else_key = obligations
3806            .plan
3807            .branches
3808            .iter()
3809            .find(|branch| branch.key.branch == "else")
3810            .unwrap();
3811        // The implicit else uses the whole if node, whose end moves right by
3812        // every inserted byte on that line.
3813        let inserted: usize = obligations
3814            .plan
3815            .edits
3816            .iter()
3817            .map(|edit| edit.text.len())
3818            .sum();
3819        assert_eq!(else_key.key.span.end, [2, 17 + inserted]);
3820        assert!(
3821            then_key.hits.len() == 1,
3822            "modifier body statement proven by the then key"
3823        );
3824    }
3825
3826    #[test]
3827    fn line_events_prove_bodies_and_probes_prove_the_rest() {
3828        // Ruby 3.4+ reads no branch or method keys. A body's first statement
3829        // implies the branch, the method and the decision outcome; an `if`
3830        // without `else`, a `&.` and a `case` without `else` are probed; the
3831        // ids Ruby 3.3 still proves through its keys are not declared as
3832        // probe-only.
3833        let source = "def both(a)\n  if a\n    1\n  else\n    2\n  end\nend\n\ndef guard(a)\n  return 0 if a\n  a&.size\nend\n\ndef pick(v)\n  case v\n  when 1 then :one\n  when 2\n    :two\n  end\nend\n\ndef short = 3\n\ndef empty; end\n";
3834        let mut probe = 0;
3835        let obligations =
3836            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
3837        let plan = &obligations.plan;
3838        let manifest = &obligations.manifest;
3839        let function = |name: &str| {
3840            manifest
3841                .points
3842                .iter()
3843                .find(|point| {
3844                    point.kind == PointKind::Function && point.label.as_deref() == Some(name)
3845                })
3846                .unwrap()
3847                .id
3848                .clone()
3849        };
3850        let statement_on = |line: usize| plan.lines.get(&line).cloned().unwrap();
3851        let transformed = String::from_utf8(apply_edits(source.as_bytes(), &plan.edits)).unwrap();
3852        assert_eq!(transformed.lines().count(), source.lines().count());
3853
3854        // `if a ... else ... end`: line 3 proves `both` and the true outcome,
3855        // line 5 the false outcome; no predicate probe.
3856        assert_eq!(
3857            plan.implied[&statement_on(2)].hits,
3858            [function("both")],
3859            "the `if` statement opens the method"
3860        );
3861        let three = &plan.implied[&statement_on(3)];
3862        assert!(three.hits.is_empty());
3863        assert_eq!(three.decisions.len(), 1);
3864        assert!(three.decisions[0].value);
3865        let five = &plan.implied[&statement_on(5)];
3866        assert!(!five.decisions[0].value);
3867        assert!(!transformed.contains(".d(") || transformed.matches(".d(").count() == 1);
3868
3869        // `return 0 if a`: the predicate is probed (one `d`, no `c`), and the
3870        // body is implied by the true outcome, not by its line.
3871        let guard_decision = manifest
3872            .decisions
3873            .iter()
3874            .find(|d| d.kind == "if" && d.conditions == ["a"] && d.line == 10)
3875            .unwrap();
3876        let outcome_true = format!("{}:outcome:true", guard_decision.id);
3877        assert!(
3878            plan.implied.contains_key(&outcome_true),
3879            "modifier body implied by its outcome"
3880        );
3881        assert!(transformed.contains("return 0 if $__supercov.d("));
3882        assert!(
3883            !transformed.contains(".c("),
3884            "a lone condition needs no wrapper"
3885        );
3886        // `a&.size`: the receiver is probed.
3887        assert!(transformed.contains("$__supercov.n("));
3888        // `case` without `else` gets an else of its own; the one-line `when`
3889        // body is a probed statement.
3890        assert!(transformed.contains("else $__supercov.hs("));
3891        assert!(transformed.contains("when 1 then $__supercov.s("));
3892        // Endless and empty methods.
3893        assert!(transformed.contains("def short = ($__supercov.s("));
3894        assert!(transformed.contains("def empty; $__supercov.hs("));
3895        let empty_probe = plan
3896            .edits
3897            .iter()
3898            .find(|edit| edit.text.contains(".hs(") && edit.text.starts_with("; "))
3899            .unwrap();
3900        assert!(empty_probe.text.contains("hs"));
3901        // Everything 3.3 proves through keys stays out of the 3.3 declaration:
3902        // the modifier body statement, the guard decision, the `&.` branch,
3903        // the case alternatives and the methods.
3904        for id in [
3905            guard_decision.id.clone(),
3906            function("empty"),
3907            function("short"),
3908        ] {
3909            assert!(
3910                !plan.probe_obligations.contains(&id),
3911                "{id} is key-provable on 3.3"
3912            );
3913        }
3914        let safe = manifest
3915            .branches
3916            .iter()
3917            .find(|b| b.kind == "safe-navigation")
3918            .unwrap();
3919        assert!(!plan.probe_obligations.contains(&safe.id));
3920    }
3921
3922    #[test]
3923    fn ractor_blocks_get_no_probes_and_declare_what_only_probes_could_prove() {
3924        // A probe inside a Ractor reads a global the Ractor cannot see and
3925        // raises where the untouched program ran. Nothing is inserted inside
3926        // the block; the same shapes outside it are probed as ever.
3927        let source = "def inside(a)\n  Ractor.new(a) { |v| v ? 1 : 2 }.take\nend\n\ndef outside(a)\n  a ? 1 : 2\nend\n";
3928        let mut probe = 0;
3929        let obligations =
3930            build_ruby_obligations("lib/r.rb", source.as_bytes(), &mut probe).unwrap();
3931        let plan = &obligations.plan;
3932        let manifest = &obligations.manifest;
3933        let transformed = String::from_utf8(apply_edits(source.as_bytes(), &plan.edits)).unwrap();
3934        let block_line = transformed.lines().nth(1).unwrap();
3935        assert!(
3936            !block_line.contains("$__supercov"),
3937            "no probe inside the Ractor block: {block_line}"
3938        );
3939        assert!(
3940            transformed
3941                .lines()
3942                .nth(5)
3943                .unwrap()
3944                .contains("$__supercov.d("),
3945            "the ternary outside is probed"
3946        );
3947        let inside = manifest.decisions.iter().find(|d| d.line == 2).unwrap();
3948        assert!(
3949            manifest.unmeasured.contains(&inside.id),
3950            "the block's ternary leaves the denominator"
3951        );
3952        let outside = manifest.decisions.iter().find(|d| d.line == 6).unwrap();
3953        assert!(!manifest.unmeasured.contains(&outside.id));
3954        assert_eq!(plan.ractor_blocks.len(), 1);
3955        assert!(
3956            manifest
3957                .limitations
3958                .iter()
3959                .any(|l| l["id"] == RACTOR_BLOCK_LIMITATION && l["line"] == 2)
3960        );
3961        // Dropped probes are not in the plan either, so 3.3 declares nothing for them.
3962        assert!(!plan.probe_obligations.contains(&inside.id));
3963    }
3964
3965    #[test]
3966    fn an_empty_guarded_in_body_takes_its_probe_after_the_guard_wrapper() {
3967        // net-imap: `in Array if data.all? { ... }` with no body. The guard's
3968        // decision wrapper closes exactly where the body would start; the
3969        // body probe must come after it. Found by the corpus sweep.
3970        let source = "def f(data)\n  case data\n  in String then data = 1\n  in Array if data.all? { _1 > 0 }\n  else\n    raise TypeError\n  end\nend\n";
3971        let mut probe = 0;
3972        let obligations =
3973            build_ruby_obligations("lib/g.rb", source.as_bytes(), &mut probe).unwrap();
3974        let transformed =
3975            String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
3976        let guard_line = transformed.lines().nth(3).unwrap();
3977        assert!(
3978            guard_line.contains(")) then $__supercov.hs("),
3979            "probe after the wrapper: {guard_line}"
3980        );
3981        assert!(
3982            !guard_line.contains("then $__supercov.hs(")
3983                || !guard_line.contains("hs(")
3984                || guard_line.matches(')').count() == guard_line.matches('(').count(),
3985            "balanced: {guard_line}"
3986        );
3987    }
3988
3989    #[test]
3990    fn a_class_variable_or_assignment_is_never_read_before_it_exists() {
3991        let source = "class C\n  def self.ext\n    @@ext ||= {}\n  end\nend\n";
3992        let mut probe = 0;
3993        let obligations =
3994            build_ruby_obligations("lib/c.rb", source.as_bytes(), &mut probe).unwrap();
3995        let transformed =
3996            String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
3997        let line = transformed.lines().nth(2).unwrap();
3998        assert!(
3999            !line.contains(".l("),
4000            "no read of @@ext before the assignment: {line}"
4001        );
4002        assert!(
4003            line.contains(".pre(") && line.contains(".es("),
4004            "arrival form: {line}"
4005        );
4006    }
4007
4008    #[test]
4009    fn a_predicate_ruby_folds_leaves_no_obligations_in_the_dead_arm() {
4010        // optparse: `if Process.respond_to?(:fork) and false` -- Ruby never
4011        // compiles the then arm and reports no branch for the ternary inside
4012        // it, so neither is an obligation; the live operand still runs.
4013        let source = "def f(x)\n  if x and false\n    x ? 1 : 2\n  end\n  if x or true\n    3\n  else\n    4\n  end\nend\n";
4014        let mut probe = 0;
4015        let obligations =
4016            build_ruby_obligations("lib/f.rb", source.as_bytes(), &mut probe).unwrap();
4017        let manifest = &obligations.manifest;
4018        assert!(
4019            !manifest.decisions.iter().any(|d| d.kind == "ternary"),
4020            "the dead arm's ternary is not an obligation"
4021        );
4022        assert!(
4023            !manifest.decisions.iter().any(|d| d.kind == "if"),
4024            "a folded predicate is no decision"
4025        );
4026        assert!(
4027            !manifest.points.iter().any(|p| p.line == 8),
4028            "the dead else arm holds no statement"
4029        );
4030        assert!(
4031            manifest.points.iter().any(|p| p.line == 6),
4032            "the live arm does"
4033        );
4034    }
4035
4036    #[test]
4037    fn rejects_invalid_ruby() {
4038        let mut probe = 0;
4039        assert!(matches!(
4040            build_ruby_obligations("m.rb", b"def x(\n", &mut probe),
4041            Err(RubyInstrumenterError::Parse(_))
4042        ));
4043    }
4044}