Skip to main content

supercov_engine/
python_instrumenter.rs

1//! Supercov-owned Python obligation discovery.
2//!
3//! Ruff's Rust parser supplies syntax and exact byte ranges. Supercov owns the
4//! denominator: every statement, function, decision and branch obligation is
5//! decided here, ahead of the run, from source alone. Alongside the shared
6//! [`CoverageManifest`] this module emits a *probe plan*: the source spans,
7//! `not` polarity, and/or trees and trigger lines the stdlib-only Python
8//! runtime needs to map `sys.monitoring` events back onto those obligations.
9//! The runtime never decides what counts; it only reports what it observed.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use ruff_python_ast::{
14    BoolOp, CmpOp, Comprehension, Expr, Stmt, UnaryOp,
15    helpers::is_docstring_stmt,
16    visitor::{Visitor, walk_comprehension, walk_expr, walk_stmt},
17};
18use ruff_python_parser::parse_module;
19use ruff_text_size::{Ranged, TextRange};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use crate::{
24    coverage_analysis::PointKind,
25    coverage_report::{
26        BranchAlternativeMeta, BranchMeta, CoverageManifest, DecisionMeta, PointMeta,
27    },
28};
29
30pub const PYTHON_PROBE_PLAN_VERSION: u32 = 1;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum PythonInstrumenterError {
34    SourceTooLarge,
35    Parse(String),
36    InvalidRange,
37}
38
39impl std::fmt::Display for PythonInstrumenterError {
40    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::SourceTooLarge => write!(formatter, "Python source exceeds the parser range"),
43            Self::Parse(error) => write!(formatter, "Python parse failed: {error}"),
44            Self::InvalidRange => write!(formatter, "Python parser returned an invalid range"),
45        }
46    }
47}
48
49impl std::error::Error for PythonInstrumenterError {}
50
51/// A source span in one-based lines and zero-based UTF-8 byte columns, the
52/// same units CPython reports through `co_positions()`. Serialized as
53/// `[[line, column], [line, column]]` so the runtime unpacks it directly.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(from = "[[usize; 2]; 2]", into = "[[usize; 2]; 2]")]
56pub struct PlanSpan {
57    pub start: [usize; 2],
58    pub end: [usize; 2],
59}
60
61impl From<[[usize; 2]; 2]> for PlanSpan {
62    fn from(value: [[usize; 2]; 2]) -> Self {
63        Self {
64            start: value[0],
65            end: value[1],
66        }
67    }
68}
69
70impl From<PlanSpan> for [[usize; 2]; 2] {
71    fn from(value: PlanSpan) -> Self {
72        [value.start, value.end]
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct StatementPlan {
79    pub id: String,
80    /// Inclusive line range in which a `LINE` event proves the statement ran.
81    pub lines: [usize; 2],
82    /// Start and end positions of the statement in line and byte column.
83    pub start: [usize; 2],
84    pub end: [usize; 2],
85    /// True when an earlier statement already owns the first line, so only an
86    /// `INSTRUCTION` event at the statement's first instruction can prove it.
87    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
88    pub exact: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct FunctionPlan {
94    pub id: String,
95    /// `co_firstlineno` of the code object: the first decorator line.
96    pub line: usize,
97    pub name: String,
98    /// Whole definition span; disambiguates several lambdas on one line.
99    pub span: PlanSpan,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase", deny_unknown_fields)]
104pub struct HandlerPlan {
105    pub id: String,
106    /// From `except` up to the handler body: the type-match instructions live
107    /// here. Empty-span bare handlers have no test.
108    pub header: PlanSpan,
109    pub body_lines: [usize; 2],
110    pub bare: bool,
111    pub missed: String,
112    pub selected: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct TryPlan {
118    pub id: String,
119    pub body: PlanSpan,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub orelse: Option<PlanSpan>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub finalbody: Option<PlanSpan>,
124    pub handlers: Vec<HandlerPlan>,
125    pub success: String,
126    pub raised: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub struct ConditionPlan {
132    pub span: PlanSpan,
133    /// Number of `not` operators wrapping the tested operand; odd depth inverts
134    /// the truthiness the conditional jump observes.
135    pub not: usize,
136    /// For CPython's specialized `POP_JUMP_IF_(NOT_)NONE` instructions: true
137    /// when the un-negated source condition is `value is None`, false for
138    /// `value is not None`, absent for every other expression.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub none_when_true: Option<bool>,
141}
142
143/// Short-circuit structure of a decision. Leaves are condition indexes. A
144/// negated node models `not (a and b)`: CPython still emits one jump per
145/// operand, so the operands stay separate conditions and the negation applies
146/// to the node's result.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(untagged)]
149pub enum ConditionTree {
150    Leaf(usize),
151    Node {
152        op: String,
153        items: Vec<ConditionTree>,
154        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
155        negate: bool,
156    },
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase", deny_unknown_fields)]
161pub struct DecisionPlan {
162    pub id: String,
163    pub kind: String,
164    pub span: PlanSpan,
165    pub conditions: Vec<ConditionPlan>,
166    pub tree: ConditionTree,
167    /// Present for comprehension filters: CPython 3.13+ stamps their jumps
168    /// with the element expression's position, so the runtime falls back to
169    /// offset order inside this span.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub comprehension: Option<PlanSpan>,
172    pub outcome_true: String,
173    pub outcome_false: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178pub struct LoopPlan {
179    pub id: String,
180    pub iter: PlanSpan,
181    pub zero: String,
182    pub entered: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct LogicalPlan {
188    pub id: String,
189    pub boolop: PlanSpan,
190    /// Index of the right operand this branch describes (1-based within the
191    /// BoolOp's operand list).
192    pub operand: usize,
193    /// When the BoolOp is part of a decision's and/or tree, the runtime derives
194    /// this branch from the decision vector using these leaf index groups:
195    /// leaves of the previous operand and leaves of this operand.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub decision: Option<String>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub previous_leaves: Option<Vec<usize>>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub operand_leaves: Option<Vec<usize>>,
202    pub short_circuit: String,
203    pub evaluated: String,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase", deny_unknown_fields)]
208pub struct MatchCasePlan {
209    pub id: String,
210    pub span: PlanSpan,
211    /// Pattern plus guard: conditional jumps positioned here decide the case.
212    pub test: PlanSpan,
213    pub irrefutable: bool,
214    pub body_lines: [usize; 2],
215    pub missed: String,
216    pub selected: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct MatchNoCasePlan {
222    pub id: String,
223    pub matched: String,
224    pub unmatched: String,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct MatchPlan {
230    pub span: PlanSpan,
231    pub cases: Vec<MatchCasePlan>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub no_case: Option<MatchNoCasePlan>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238pub struct PythonFilePlan {
239    pub statements: Vec<StatementPlan>,
240    pub functions: Vec<FunctionPlan>,
241    pub decisions: Vec<DecisionPlan>,
242    pub loops: Vec<LoopPlan>,
243    pub logical: Vec<LogicalPlan>,
244    pub matches: Vec<MatchPlan>,
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub tries: Vec<TryPlan>,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase", deny_unknown_fields)]
251pub struct PythonProbePlan {
252    pub version: u32,
253    pub root: String,
254    pub files: BTreeMap<String, PythonFilePlan>,
255}
256
257#[derive(Debug, Clone, PartialEq)]
258pub struct PythonFileObligations {
259    pub manifest: CoverageManifest,
260    pub plan: PythonFilePlan,
261}
262
263struct SourceLocations<'a> {
264    source: &'a str,
265    line_starts: Vec<usize>,
266}
267
268impl<'a> SourceLocations<'a> {
269    fn new(source: &'a str) -> Self {
270        let mut line_starts = vec![0];
271        line_starts.extend(
272            source
273                .bytes()
274                .enumerate()
275                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
276        );
277        Self {
278            source,
279            line_starts,
280        }
281    }
282
283    fn range(&self, range: TextRange) -> Result<(usize, usize), PythonInstrumenterError> {
284        let start = range.start().to_usize();
285        let end = range.end().to_usize();
286        if start > end
287            || end > self.source.len()
288            || !self.source.is_char_boundary(start)
289            || !self.source.is_char_boundary(end)
290        {
291            return Err(PythonInstrumenterError::InvalidRange);
292        }
293        Ok((start, end))
294    }
295
296    fn line_column(&self, offset: usize) -> (usize, usize) {
297        let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
298        (line_index + 1, offset - self.line_starts[line_index])
299    }
300
301    fn span(&self, range: TextRange) -> Result<PlanSpan, PythonInstrumenterError> {
302        let (start, end) = self.range(range)?;
303        let (start_line, start_column) = self.line_column(start);
304        let (end_line, end_column) = self.line_column(end);
305        Ok(PlanSpan {
306            start: [start_line, start_column],
307            end: [end_line, end_column],
308        })
309    }
310
311    fn text(&self, range: TextRange) -> Result<String, PythonInstrumenterError> {
312        let (start, end) = self.range(range)?;
313        Ok(self.source[start..end].trim().to_owned())
314    }
315}
316
317fn stable_id(file: &str, kind: &str, range: TextRange, suffix: &str) -> String {
318    let mut hash = Sha256::new();
319    let start = range.start().to_usize().to_string();
320    let end = range.end().to_usize().to_string();
321    for value in [file, kind, &start, &end, suffix] {
322        hash.update(value.as_bytes());
323        hash.update([0]);
324    }
325    let digest = hash.finalize();
326    let mut encoded = String::with_capacity(24);
327    for byte in &digest[..12] {
328        use std::fmt::Write as _;
329        write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
330    }
331    format!("py:{kind}:{encoded}")
332}
333
334/// Statements whose execution CPython never reports: docstrings are folded
335/// into `__doc__` without an instruction and scope declarations compile to
336/// nothing.
337fn is_unobservable_statement(statement: &Stmt, first_in_body: bool) -> bool {
338    matches!(statement, Stmt::Global(_) | Stmt::Nonlocal(_))
339        || (first_in_body && is_docstring_stmt(statement))
340}
341
342fn first_body_statement(statement: &Stmt) -> Option<&Stmt> {
343    match statement {
344        Stmt::FunctionDef(inner) => inner.body.first(),
345        Stmt::ClassDef(inner) => inner.body.first(),
346        Stmt::If(inner) => inner.body.first(),
347        Stmt::While(inner) => inner.body.first(),
348        Stmt::For(inner) => inner.body.first(),
349        Stmt::With(inner) => inner.body.first(),
350        Stmt::Try(inner) => inner.body.first(),
351        Stmt::Match(inner) => inner.cases.first().and_then(|case| case.body.first()),
352        _ => None,
353    }
354}
355
356/// A BoolOp that belongs to a decision's tree: the decision ID and the leaf
357/// indexes contributed by each operand.
358type DecisionBoolOp = (String, Vec<Vec<usize>>);
359
360struct PythonObligationCollector<'a> {
361    file: &'a str,
362    locations: SourceLocations<'a>,
363    manifest: CoverageManifest,
364    plan: PythonFilePlan,
365    point_ids: BTreeSet<String>,
366    decision_ids: BTreeSet<String>,
367    branch_ids: BTreeSet<String>,
368    /// Lines already claimed by a statement trigger; later statements on the
369    /// same line are proven by an `INSTRUCTION` event at their first
370    /// instruction instead of a `LINE` event.
371    claimed_lines: BTreeSet<usize>,
372    /// BoolOp ranges that form a decision's and/or tree, with the decision ID
373    /// and the leaf indexes of each operand.
374    decision_boolops: BTreeMap<(usize, usize), DecisionBoolOp>,
375    error: Option<PythonInstrumenterError>,
376}
377
378impl<'a> PythonObligationCollector<'a> {
379    fn new(file: &'a str, source: &'a str) -> Self {
380        Self {
381            file,
382            locations: SourceLocations::new(source),
383            manifest: CoverageManifest {
384                unmeasured: Vec::new(),
385                decisions: Vec::new(),
386                points: Vec::new(),
387                branches: Vec::new(),
388                limitations: Vec::new(),
389                scope: None,
390            },
391            plan: PythonFilePlan::default(),
392            point_ids: BTreeSet::new(),
393            decision_ids: BTreeSet::new(),
394            branch_ids: BTreeSet::new(),
395            claimed_lines: BTreeSet::new(),
396            decision_boolops: BTreeMap::new(),
397            error: None,
398        }
399    }
400
401    fn fail<T>(&mut self, error: PythonInstrumenterError) -> Option<T> {
402        self.error.get_or_insert(error);
403        None
404    }
405
406    fn location_source(&mut self, range: TextRange) -> Option<(usize, usize, String)> {
407        let result = self.locations.range(range).map(|(start, _)| {
408            let (line, column) = self.locations.line_column(start);
409            (line, column, self.locations.text(range))
410        });
411        match result {
412            Ok((line, column, Ok(source))) => Some((line, column, source)),
413            Ok((_, _, Err(error))) | Err(error) => self.fail(error),
414        }
415    }
416
417    fn span(&mut self, range: TextRange) -> Option<PlanSpan> {
418        match self.locations.span(range) {
419            Ok(span) => Some(span),
420            Err(error) => self.fail(error),
421        }
422    }
423
424    fn push_point(&mut self, id: &str, range: TextRange, kind: PointKind, label: Option<String>) {
425        let Some((line, column, source)) = self.location_source(range) else {
426            return;
427        };
428        self.manifest.points.push(PointMeta {
429            id: id.into(),
430            kind,
431            file: self.file.into(),
432            line,
433            column,
434            source,
435            label,
436        });
437    }
438
439    fn statement(&mut self, statement: &Stmt) {
440        let range = statement.range();
441        let id = stable_id(self.file, "statement", range, "");
442        if !self.point_ids.insert(id.clone()) {
443            return;
444        }
445        self.push_point(&id, range, PointKind::Statement, None);
446        let Some(span) = self.span(range) else {
447            return;
448        };
449        let start_line = span.start[0];
450        // A compound statement is proven by its header expressions. CPython
451        // stamps the header's instructions with the lines those expressions
452        // occupy, never the keyword line alone, so claim every header line up
453        // to the first body statement.
454        let end_line = match first_body_statement(statement) {
455            Some(body) => match self.span(body.range()) {
456                Some(body_span) => body_span.start[0].saturating_sub(1).max(start_line),
457                None => return,
458            },
459            None => span.end[0],
460        };
461        let exact = self.claimed_lines.contains(&start_line);
462        if !exact {
463            for line in start_line..=end_line {
464                self.claimed_lines.insert(line);
465            }
466        }
467        self.plan.statements.push(StatementPlan {
468            id,
469            lines: [start_line, end_line],
470            start: span.start,
471            end: span.end,
472            exact,
473        });
474    }
475
476    fn function(&mut self, range: TextRange, name: &str) {
477        let id = stable_id(self.file, "function", range, name);
478        if !self.point_ids.insert(id.clone()) {
479            return;
480        }
481        self.push_point(&id, range, PointKind::Function, Some(name.to_owned()));
482        let Some(span) = self.span(range) else {
483            return;
484        };
485        self.plan.functions.push(FunctionPlan {
486            id,
487            line: span.start[0],
488            name: name.to_owned(),
489            span,
490        });
491    }
492
493    fn strip_not(expr: &Expr) -> (&Expr, usize) {
494        let mut current = expr;
495        let mut depth = 0;
496        while let Expr::UnaryOp(unary) = current {
497            if unary.op != UnaryOp::Not {
498                break;
499            }
500            depth += 1;
501            current = &unary.operand;
502        }
503        (current, depth)
504    }
505
506    fn none_when_true(expr: &Expr) -> Option<bool> {
507        let Expr::Compare(comparison) = expr else {
508            return None;
509        };
510        let [operator] = comparison.ops.as_ref() else {
511            return None;
512        };
513        let [right] = comparison.comparators.as_ref() else {
514            return None;
515        };
516        if !matches!(comparison.left.as_ref(), Expr::NoneLiteral(_))
517            && !matches!(right, Expr::NoneLiteral(_))
518        {
519            return None;
520        }
521        match operator {
522            CmpOp::Is => Some(true),
523            CmpOp::IsNot => Some(false),
524            _ => None,
525        }
526    }
527
528    /// Flatten a test expression into leaves and a short-circuit tree. A
529    /// BoolOp is part of the tree only when it is the test itself or the
530    /// direct operand of another tree BoolOp; `not (a and b)` stays one leaf
531    /// because CPython inverts the jump senses inside it rather than exposing
532    /// the operands as separate decision conditions.
533    fn tree(
534        &mut self,
535        expr: &Expr,
536        leaves: &mut Vec<ConditionPlan>,
537        boolops: &mut Vec<(TextRange, Vec<Vec<usize>>)>,
538    ) -> Option<ConditionTree> {
539        let (operand, not) = Self::strip_not(expr);
540        if let Expr::BoolOp(boolean) = operand {
541            let mut items = Vec::with_capacity(boolean.values.len());
542            let mut groups = Vec::with_capacity(boolean.values.len());
543            for value in &boolean.values {
544                let first = leaves.len();
545                items.push(self.tree(value, leaves, boolops)?);
546                groups.push((first..leaves.len()).collect());
547            }
548            boolops.push((boolean.range, groups));
549            return Some(ConditionTree::Node {
550                op: match boolean.op {
551                    BoolOp::And => "and".into(),
552                    BoolOp::Or => "or".into(),
553                },
554                items,
555                negate: not % 2 == 1,
556            });
557        }
558        let span = self.span(operand.range())?;
559        leaves.push(ConditionPlan {
560            span,
561            not,
562            none_when_true: Self::none_when_true(operand),
563        });
564        Some(ConditionTree::Leaf(leaves.len() - 1))
565    }
566
567    fn decision(&mut self, test: &Expr, kind: &str, comprehension: Option<TextRange>) {
568        let range = test.range();
569        let id = stable_id(self.file, "decision", range, kind);
570        if !self.decision_ids.insert(id.clone()) {
571            return;
572        }
573        let Some((line, column, source)) = self.location_source(range) else {
574            return;
575        };
576        let mut leaves = Vec::new();
577        let mut boolops = Vec::new();
578        let Some(tree) = self.tree(test, &mut leaves, &mut boolops) else {
579            return;
580        };
581        let mut conditions = Vec::with_capacity(leaves.len());
582        for leaf in &leaves {
583            let leaf_range = TextRange::new(
584                self.locations.line_starts[leaf.span.start[0] - 1]
585                    .saturating_add(leaf.span.start[1])
586                    .try_into()
587                    .unwrap_or_default(),
588                self.locations.line_starts[leaf.span.end[0] - 1]
589                    .saturating_add(leaf.span.end[1])
590                    .try_into()
591                    .unwrap_or_default(),
592            );
593            match self.locations.text(leaf_range) {
594                Ok(text) => conditions.push(if leaf.not % 2 == 1 {
595                    format!("not {text}")
596                } else {
597                    text
598                }),
599                Err(error) => {
600                    self.fail::<()>(error);
601                    return;
602                }
603            }
604        }
605        for (boolop_range, groups) in boolops {
606            let (start, end) = match self.locations.range(boolop_range) {
607                Ok(bounds) => bounds,
608                Err(error) => {
609                    self.fail::<()>(error);
610                    return;
611                }
612            };
613            self.decision_boolops
614                .insert((start, end), (id.clone(), groups));
615        }
616        self.manifest.decisions.push(DecisionMeta {
617            id: id.clone(),
618            file: self.file.into(),
619            line,
620            column,
621            source: source.clone(),
622            conditions,
623            kind: kind.into(),
624        });
625        let outcome_id = format!("{id}:outcome");
626        self.branch_with_id(
627            outcome_id.clone(),
628            range,
629            kind,
630            source,
631            [("true", "true"), ("false", "false")],
632        );
633        let Some(span) = self.span(range) else {
634            return;
635        };
636        let comprehension = match comprehension {
637            Some(range) => match self.span(range) {
638                Some(span) => Some(span),
639                None => return,
640            },
641            None => None,
642        };
643        self.plan.decisions.push(DecisionPlan {
644            id,
645            kind: kind.into(),
646            span,
647            conditions: leaves,
648            tree,
649            comprehension,
650            outcome_true: format!("{outcome_id}:true"),
651            outcome_false: format!("{outcome_id}:false"),
652        });
653    }
654
655    fn branch<const N: usize>(
656        &mut self,
657        range: TextRange,
658        kind: &str,
659        alternatives: [(&str, &str); N],
660    ) -> Option<String> {
661        let id = stable_id(self.file, "branch", range, kind);
662        let (_, _, source) = self.location_source(range)?;
663        self.branch_with_id(id, range, kind, source, alternatives)
664    }
665
666    fn branch_with_id<const N: usize>(
667        &mut self,
668        id: String,
669        range: TextRange,
670        kind: &str,
671        source: String,
672        alternatives: [(&str, &str); N],
673    ) -> Option<String> {
674        if !self.branch_ids.insert(id.clone()) {
675            return None;
676        }
677        let (line, column, _) = self.location_source(range)?;
678        self.manifest.branches.push(BranchMeta {
679            id: id.clone(),
680            kind: kind.into(),
681            file: self.file.into(),
682            line,
683            column,
684            source,
685            alternatives: alternatives
686                .into_iter()
687                .map(|(suffix, label)| BranchAlternativeMeta {
688                    id: format!("{id}:{suffix}"),
689                    label: label.into(),
690                })
691                .collect(),
692        });
693        Some(id)
694    }
695
696    fn loop_branch(&mut self, range: TextRange, iter: &Expr, kind: &str) {
697        let Some(id) = self.branch(
698            range,
699            kind,
700            [("zero", "zero iterations"), ("entered", "entered")],
701        ) else {
702            return;
703        };
704        let Some(iter_span) = self.span(iter.range()) else {
705            return;
706        };
707        self.plan.loops.push(LoopPlan {
708            zero: format!("{id}:zero"),
709            entered: format!("{id}:entered"),
710            id,
711            iter: iter_span,
712        });
713    }
714
715    fn try_statement(&mut self, statement: &ruff_python_ast::StmtTry) {
716        let Some(id) = self.branch(
717            statement.range,
718            if statement.is_star { "try-star" } else { "try" },
719            [("success", "try completed"), ("raised", "handler entered")],
720        ) else {
721            return;
722        };
723        let body_range = match (statement.body.first(), statement.body.last()) {
724            (Some(first), Some(last)) => TextRange::new(first.start(), last.end()),
725            _ => return,
726        };
727        let Some(body) = self.span(body_range) else {
728            return;
729        };
730        let block_span = |collector: &mut Self, block: &[Stmt]| -> Option<Option<PlanSpan>> {
731            match (block.first(), block.last()) {
732                (Some(first), Some(last)) => collector
733                    .span(TextRange::new(first.start(), last.end()))
734                    .map(Some),
735                _ => Some(None),
736            }
737        };
738        let Some(orelse) = block_span(self, &statement.orelse) else {
739            return;
740        };
741        let Some(finalbody) = block_span(self, &statement.finalbody) else {
742            return;
743        };
744        let mut handlers = Vec::with_capacity(statement.handlers.len());
745        for (index, handler) in statement.handlers.iter().enumerate() {
746            let Some(handler_id) = self.branch(
747                handler.range(),
748                &format!("except-{index}"),
749                [("missed", "not selected"), ("selected", "selected")],
750            ) else {
751                return;
752            };
753            let ruff_python_ast::ExceptHandler::ExceptHandler(clause) = handler;
754            let (Some(first), Some(last)) = (clause.body.first(), clause.body.last()) else {
755                return;
756            };
757            let header_range = TextRange::new(clause.range.start(), first.start());
758            let (Some(header), Some(first_span), Some(last_span)) = (
759                self.span(header_range),
760                self.span(first.range()),
761                self.span(last.range()),
762            ) else {
763                return;
764            };
765            handlers.push(HandlerPlan {
766                missed: format!("{handler_id}:missed"),
767                selected: format!("{handler_id}:selected"),
768                id: handler_id,
769                header,
770                body_lines: [first_span.start[0], last_span.end[0]],
771                bare: clause.type_.is_none(),
772            });
773        }
774        self.plan.tries.push(TryPlan {
775            success: format!("{id}:success"),
776            raised: format!("{id}:raised"),
777            id,
778            body,
779            orelse,
780            finalbody,
781            handlers,
782        });
783    }
784
785    fn logical(&mut self, boolean: &ruff_python_ast::ExprBoolOp) {
786        let Some(boolop_span) = self.span(boolean.range) else {
787            return;
788        };
789        let bounds = match self.locations.range(boolean.range) {
790            Ok(bounds) => bounds,
791            Err(error) => {
792                self.fail::<()>(error);
793                return;
794            }
795        };
796        let decision = self.decision_boolops.get(&bounds).cloned();
797        let op = match boolean.op {
798            BoolOp::And => "and",
799            BoolOp::Or => "or",
800        };
801        for (index, value) in boolean.values.iter().enumerate().skip(1) {
802            let Some(id) = self.branch(
803                value.range(),
804                &format!("logical-{op}-{index}"),
805                [
806                    ("short-circuit", "short-circuited"),
807                    ("evaluated", "right operand evaluated"),
808                ],
809            ) else {
810                continue;
811            };
812            let (decision_id, previous_leaves, operand_leaves) = match &decision {
813                Some((decision_id, groups)) => (
814                    Some(decision_id.clone()),
815                    Some(groups[index - 1].clone()),
816                    Some(groups[index].clone()),
817                ),
818                None => (None, None, None),
819            };
820            self.plan.logical.push(LogicalPlan {
821                short_circuit: format!("{id}:short-circuit"),
822                evaluated: format!("{id}:evaluated"),
823                id,
824                boolop: boolop_span,
825                operand: index,
826                decision: decision_id,
827                previous_leaves,
828                operand_leaves,
829            });
830        }
831    }
832
833    fn match_statement(&mut self, statement: &ruff_python_ast::StmtMatch) {
834        let Some(span) = self.span(statement.range) else {
835            return;
836        };
837        let mut cases = Vec::with_capacity(statement.cases.len());
838        for (index, case) in statement.cases.iter().enumerate() {
839            let kind = format!("match-case-{index}");
840            let Some(id) = self.branch(
841                case.range,
842                &kind,
843                [("missed", "not selected"), ("selected", "selected")],
844            ) else {
845                return;
846            };
847            if let Some(guard) = &case.guard {
848                self.decision(guard, "match-guard", None);
849            }
850            let test_range = match &case.guard {
851                Some(guard) => TextRange::new(case.pattern.start(), guard.end()),
852                None => case.pattern.range(),
853            };
854            let (Some(case_span), Some(test)) = (self.span(case.range), self.span(test_range))
855            else {
856                return;
857            };
858            let body_lines = match (case.body.first(), case.body.last()) {
859                (Some(first), Some(last)) => {
860                    match (self.span(first.range()), self.span(last.range())) {
861                        (Some(first), Some(last)) => [first.start[0], last.end[0]],
862                        _ => return,
863                    }
864                }
865                _ => [case_span.start[0], case_span.end[0]],
866            };
867            cases.push(MatchCasePlan {
868                missed: format!("{id}:missed"),
869                selected: format!("{id}:selected"),
870                id,
871                span: case_span,
872                test,
873                irrefutable: case.guard.is_none() && case.pattern.is_irrefutable(),
874                body_lines,
875            });
876        }
877        let no_case = if statement
878            .cases
879            .iter()
880            .any(|case| case.guard.is_none() && case.pattern.is_irrefutable())
881        {
882            None
883        } else {
884            self.branch(
885                statement.subject.range(),
886                "match-no-case",
887                [
888                    ("matched", "some case matched"),
889                    ("unmatched", "no case matched"),
890                ],
891            )
892            .map(|id| MatchNoCasePlan {
893                matched: format!("{id}:matched"),
894                unmatched: format!("{id}:unmatched"),
895                id,
896            })
897        };
898        self.plan.matches.push(MatchPlan {
899            span,
900            cases,
901            no_case,
902        });
903    }
904
905    fn visit_body_statements(&mut self, body: &'a [Stmt]) {
906        for (index, statement) in body.iter().enumerate() {
907            if is_unobservable_statement(statement, index == 0) {
908                // Its expressions still may not contain obligations worth
909                // walking (docstrings, names), so skip entirely.
910                continue;
911            }
912            self.visit_stmt(statement);
913        }
914    }
915}
916
917impl<'a> Visitor<'a> for PythonObligationCollector<'a> {
918    fn visit_body(&mut self, body: &'a [Stmt]) {
919        self.visit_body_statements(body);
920    }
921
922    fn visit_stmt(&mut self, statement: &'a Stmt) {
923        self.statement(statement);
924        match statement {
925            Stmt::FunctionDef(function) => self.function(function.range, &function.name),
926            Stmt::If(statement) => {
927                self.decision(&statement.test, "if", None);
928                for clause in &statement.elif_else_clauses {
929                    if let Some(test) = &clause.test {
930                        self.decision(test, "elif", None);
931                    }
932                }
933            }
934            Stmt::While(statement) => {
935                self.decision(&statement.test, "while", None);
936            }
937            Stmt::For(statement) => self.loop_branch(
938                statement.range,
939                &statement.iter,
940                if statement.is_async {
941                    "async-for"
942                } else {
943                    "for"
944                },
945            ),
946            Stmt::Match(statement) => self.match_statement(statement),
947            Stmt::Try(statement) => self.try_statement(statement),
948            Stmt::Assert(statement) => {
949                self.decision(&statement.test, "assert", None);
950            }
951            _ => {}
952        }
953        walk_stmt(self, statement);
954    }
955
956    fn visit_expr(&mut self, expression: &'a Expr) {
957        match expression {
958            Expr::Lambda(lambda) => self.function(lambda.range, "<lambda>"),
959            Expr::If(expression) => {
960                self.decision(&expression.test, "ternary", None);
961            }
962            Expr::BoolOp(expression) => self.logical(expression),
963            _ => {}
964        }
965        walk_expr(self, expression);
966    }
967
968    fn visit_comprehension(&mut self, comprehension: &'a Comprehension) {
969        self.loop_branch(
970            comprehension.range,
971            &comprehension.iter,
972            if comprehension.is_async {
973                "async-comprehension"
974            } else {
975                "comprehension"
976            },
977        );
978        for condition in &comprehension.ifs {
979            self.decision(condition, "comprehension-if", Some(comprehension.range));
980        }
981        walk_comprehension(self, comprehension);
982    }
983}
984
985/// Build the complete obligation manifest and runtime probe plan for one
986/// Python source file. Limitations are per file; the run-level frontend
987/// deduplicates their IDs across files.
988pub fn build_python_obligations(
989    file: &str,
990    source: &str,
991) -> Result<PythonFileObligations, PythonInstrumenterError> {
992    if source.len() > u32::MAX as usize {
993        return Err(PythonInstrumenterError::SourceTooLarge);
994    }
995    let parsed =
996        parse_module(source).map_err(|error| PythonInstrumenterError::Parse(error.to_string()))?;
997    let mut collector = PythonObligationCollector::new(file, source);
998    collector.visit_body(parsed.suite());
999    if let Some(error) = collector.error {
1000        return Err(error);
1001    }
1002    collector.manifest.unmeasured.sort();
1003    collector.manifest.unmeasured.dedup();
1004    Ok(PythonFileObligations {
1005        manifest: collector.manifest,
1006        plan: collector.plan,
1007    })
1008}
1009
1010/// Manifest-only view kept for callers that predate the probe plan.
1011pub fn build_python_manifest(
1012    file: &str,
1013    source: &str,
1014) -> Result<CoverageManifest, PythonInstrumenterError> {
1015    build_python_obligations(file, source).map(|obligations| obligations.manifest)
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    const SOURCE: &str = r#"async def classify[T](items, flag=True):
1023    values = [item async for item in items if item.ready and flag]
1024    for value in values:
1025        if value.primary and (value.safe or flag):
1026            return value if flag else None
1027        elif value.fallback:
1028            break
1029    try:
1030        match values:
1031            case [first, *_] if first.ready:
1032                return first
1033            case []:
1034                return None
1035    except* ValueError:
1036        return None
1037    return (lambda value: value or flag)(None)
1038"#;
1039
1040    #[test]
1041    fn discovers_current_python_obligations_with_exact_ranges_and_stable_ids() {
1042        let first = build_python_obligations("src/app.py", SOURCE).unwrap();
1043        let second = build_python_obligations("src/app.py", SOURCE).unwrap();
1044        assert_eq!(first, second);
1045        let manifest = &first.manifest;
1046        assert!(manifest.points.iter().any(|point| {
1047            point.kind == PointKind::Function && point.label.as_deref() == Some("classify")
1048        }));
1049        assert!(manifest.points.iter().any(|point| {
1050            point.kind == PointKind::Function && point.label.as_deref() == Some("<lambda>")
1051        }));
1052        let if_decision = manifest
1053            .decisions
1054            .iter()
1055            .find(|decision| decision.kind == "if")
1056            .unwrap();
1057        assert_eq!(
1058            if_decision.conditions,
1059            ["value.primary", "value.safe", "flag"]
1060        );
1061        assert_eq!(if_decision.line, 4);
1062        assert_eq!(if_decision.column, 11);
1063        for kind in ["ternary", "match-guard", "comprehension-if", "elif"] {
1064            assert!(
1065                manifest
1066                    .decisions
1067                    .iter()
1068                    .any(|decision| decision.kind == kind),
1069                "missing {kind}"
1070            );
1071        }
1072        assert!(
1073            manifest
1074                .branches
1075                .iter()
1076                .any(|branch| branch.kind == "async-comprehension")
1077        );
1078        assert!(
1079            manifest
1080                .branches
1081                .iter()
1082                .any(|branch| branch.kind == "try-star")
1083        );
1084        assert!(
1085            manifest
1086                .branches
1087                .iter()
1088                .any(|branch| branch.kind.starts_with("logical-and"))
1089        );
1090        assert!(
1091            manifest
1092                .decisions
1093                .iter()
1094                .all(|decision| decision.id.starts_with("py:decision:"))
1095        );
1096        assert!(manifest.unmeasured.is_empty());
1097        assert!(manifest.limitations.is_empty());
1098    }
1099
1100    #[test]
1101    fn plan_carries_spans_polarity_trees_and_trigger_lines() {
1102        let plan = build_python_obligations("src/app.py", SOURCE).unwrap().plan;
1103        let if_plan = plan
1104            .decisions
1105            .iter()
1106            .find(|decision| decision.kind == "if")
1107            .unwrap();
1108        assert_eq!(if_plan.conditions.len(), 3);
1109        assert_eq!(if_plan.conditions[0].span.start, [4, 11]);
1110        assert_eq!(if_plan.conditions[0].span.end, [4, 24]);
1111        assert_eq!(if_plan.conditions[1].span.start, [4, 30]);
1112        assert_eq!(
1113            if_plan.tree,
1114            ConditionTree::Node {
1115                op: "and".into(),
1116                items: vec![
1117                    ConditionTree::Leaf(0),
1118                    ConditionTree::Node {
1119                        op: "or".into(),
1120                        items: vec![ConditionTree::Leaf(1), ConditionTree::Leaf(2)],
1121                        negate: false,
1122                    },
1123                ],
1124                negate: false,
1125            }
1126        );
1127        // The decision-tree BoolOps produce logical branches derived from the
1128        // vector rather than from value-context jumps.
1129        let logical_or = plan
1130            .logical
1131            .iter()
1132            .find(|logical| logical.decision.as_deref() == Some(if_plan.id.as_str()))
1133            .unwrap();
1134        assert!(logical_or.previous_leaves.is_some());
1135        // The lambda's `value or flag` is value context.
1136        assert!(
1137            plan.logical
1138                .iter()
1139                .any(|logical| logical.decision.is_none())
1140        );
1141        let comprehension = plan
1142            .decisions
1143            .iter()
1144            .find(|decision| decision.kind == "comprehension-if")
1145            .unwrap();
1146        assert!(comprehension.comprehension.is_some());
1147        // `async def classify` header spans line 1 only; its body starts on 2.
1148        let function_statement = plan
1149            .statements
1150            .iter()
1151            .find(|statement| statement.lines[0] == 1)
1152            .unwrap();
1153        assert_eq!(function_statement.lines, [1, 1]);
1154        assert!(
1155            plan.functions
1156                .iter()
1157                .any(|function| function.name == "classify" && function.line == 1)
1158        );
1159        assert_eq!(plan.loops.len(), 2);
1160        assert_eq!(plan.matches.len(), 1);
1161        assert_eq!(plan.matches[0].cases.len(), 2);
1162        assert!(plan.matches[0].no_case.is_some());
1163        assert_eq!(plan.tries.len(), 1);
1164        let try_plan = &plan.tries[0];
1165        assert_eq!(try_plan.body.start, [9, 8]);
1166        assert_eq!(try_plan.handlers.len(), 1);
1167        assert_eq!(try_plan.handlers[0].body_lines, [15, 15]);
1168        assert!(!try_plan.handlers[0].bare);
1169        assert!(try_plan.finalbody.is_none());
1170    }
1171
1172    #[test]
1173    fn not_polarity_and_same_line_statements_are_modelled() {
1174        let source = "def f(a, b):\n    if not (a and b): return 1\n    x = 1; y = 2\n    g = lambda: 1; h = lambda: 2\n    return x + y\n";
1175        let obligations = build_python_obligations("m.py", source).unwrap();
1176        let decision = &obligations.plan.decisions[0];
1177        // `not (a and b)` keeps `a` and `b` as separate conditions and negates
1178        // the node, matching the one-jump-per-operand bytecode.
1179        assert_eq!(decision.conditions.len(), 2);
1180        assert_eq!(decision.conditions[0].not, 0);
1181        assert_eq!(
1182            decision.tree,
1183            ConditionTree::Node {
1184                op: "and".into(),
1185                items: vec![ConditionTree::Leaf(0), ConditionTree::Leaf(1)],
1186                negate: true,
1187            }
1188        );
1189        assert_eq!(obligations.manifest.decisions[0].conditions, ["a", "b"]);
1190        let negated_leaf = build_python_obligations(
1191            "n.py",
1192            "def g(a):
1193    if not a:
1194        return 1
1195",
1196        )
1197        .unwrap();
1198        assert_eq!(negated_leaf.plan.decisions[0].conditions[0].not, 1);
1199        assert_eq!(negated_leaf.manifest.decisions[0].conditions, ["not a"]);
1200        let none_comparisons = build_python_obligations(
1201            "none.py",
1202            "def g(a, b):\n    if a is None or b is not None:\n        return 1\n",
1203        )
1204        .unwrap();
1205        let conditions = &none_comparisons.plan.decisions[0].conditions;
1206        assert_eq!(conditions[0].none_when_true, Some(true));
1207        assert_eq!(conditions[1].none_when_true, Some(false));
1208        // `return 1` shares line 2 with the `if`, `y = 2` shares line 3 with
1209        // `x = 1`, and `h = ...` shares line 4 with `g = ...`: those three are
1210        // proven by INSTRUCTION events at their exact start, nothing is
1211        // unmeasured, and both lambdas keep their spans.
1212        let exact = obligations
1213            .plan
1214            .statements
1215            .iter()
1216            .filter(|statement| statement.exact)
1217            .map(|statement| statement.start)
1218            .collect::<Vec<_>>();
1219        assert_eq!(exact, [[2, 22], [3, 11], [4, 19]]);
1220        assert!(obligations.manifest.unmeasured.is_empty());
1221        assert!(obligations.manifest.limitations.is_empty());
1222        let lambdas = obligations
1223            .plan
1224            .functions
1225            .iter()
1226            .filter(|function| function.name == "<lambda>")
1227            .collect::<Vec<_>>();
1228        assert_eq!(lambdas.len(), 2);
1229        assert_ne!(lambdas[0].span, lambdas[1].span);
1230    }
1231
1232    #[test]
1233    fn try_statements_carry_bodies_handlers_and_finally_spans() {
1234        let source = "def g(x):\n    try:\n        y = int(x)\n    except ValueError:\n        y = -1\n    except:\n        y = -2\n    else:\n        y += 1\n    finally:\n        x = None\n    return y\n";
1235        let plan = build_python_obligations("t.py", source).unwrap().plan;
1236        let try_plan = &plan.tries[0];
1237        assert_eq!(
1238            try_plan.body,
1239            PlanSpan {
1240                start: [3, 8],
1241                end: [3, 18]
1242            }
1243        );
1244        assert_eq!(
1245            try_plan.orelse,
1246            Some(PlanSpan {
1247                start: [9, 8],
1248                end: [9, 14]
1249            })
1250        );
1251        assert_eq!(
1252            try_plan.finalbody,
1253            Some(PlanSpan {
1254                start: [11, 8],
1255                end: [11, 16]
1256            })
1257        );
1258        assert_eq!(try_plan.handlers.len(), 2);
1259        assert_eq!(
1260            try_plan.handlers[0].header,
1261            PlanSpan {
1262                start: [4, 4],
1263                end: [5, 8]
1264            }
1265        );
1266        assert!(!try_plan.handlers[0].bare);
1267        assert!(try_plan.handlers[1].bare);
1268        assert_eq!(try_plan.handlers[1].body_lines, [7, 7]);
1269    }
1270
1271    #[test]
1272    fn docstrings_and_scope_declarations_are_not_statements() {
1273        let source = "\"\"\"module doc\"\"\"\nX = 1\ndef f():\n    \"\"\"doc\"\"\"\n    global X\n    pass\n";
1274        let obligations = build_python_obligations("m.py", source).unwrap();
1275        let statements = obligations
1276            .manifest
1277            .points
1278            .iter()
1279            .filter(|point| point.kind == PointKind::Statement)
1280            .map(|point| point.line)
1281            .collect::<Vec<_>>();
1282        assert_eq!(statements, [2, 3, 6]);
1283    }
1284
1285    #[test]
1286    fn rejects_invalid_python_without_partial_obligations() {
1287        assert!(matches!(
1288            build_python_manifest("src/broken.py", "if :\n    pass\n"),
1289            Err(PythonInstrumenterError::Parse(_))
1290        ));
1291    }
1292}