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