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            },
373            plan: PythonFilePlan::default(),
374            point_ids: BTreeSet::new(),
375            decision_ids: BTreeSet::new(),
376            branch_ids: BTreeSet::new(),
377            decision_boolops: BTreeMap::new(),
378            error: None,
379        }
380    }
381
382    fn fail<T>(&mut self, error: PythonInstrumenterError) -> Option<T> {
383        self.error.get_or_insert(error);
384        None
385    }
386
387    fn location_source(&mut self, range: TextRange) -> Option<(usize, usize, String)> {
388        let result = self.locations.range(range).map(|(start, _)| {
389            let (line, column) = self.locations.line_column(start);
390            (line, column, self.locations.text(range))
391        });
392        match result {
393            Ok((line, column, Ok(source))) => Some((line, column, source)),
394            Ok((_, _, Err(error))) | Err(error) => self.fail(error),
395        }
396    }
397
398    fn span(&mut self, range: TextRange) -> Option<PlanSpan> {
399        match self.locations.span(range) {
400            Ok(span) => Some(span),
401            Err(error) => self.fail(error),
402        }
403    }
404
405    fn push_point(&mut self, id: &str, range: TextRange, kind: PointKind, label: Option<String>) {
406        let Some((line, column, source)) = self.location_source(range) else {
407            return;
408        };
409        self.manifest.points.push(PointMeta {
410            id: id.into(),
411            kind,
412            file: self.file.into(),
413            line,
414            column,
415            source,
416            label,
417        });
418    }
419
420    fn statement(&mut self, statement: &Stmt) {
421        let range = statement.range();
422        let id = stable_id(self.file, "statement", range, "");
423        if !self.point_ids.insert(id.clone()) {
424            return;
425        }
426        self.push_point(&id, range, PointKind::Statement, None);
427        let Some(span) = self.span(range) else {
428            return;
429        };
430        self.plan.statements.push(StatementPlan {
431            id,
432            start: span.start,
433        });
434    }
435
436    fn function(&mut self, range: TextRange, name: &str) {
437        let id = stable_id(self.file, "function", range, name);
438        if !self.point_ids.insert(id.clone()) {
439            return;
440        }
441        self.push_point(&id, range, PointKind::Function, Some(name.to_owned()));
442        let Some(span) = self.span(range) else {
443            return;
444        };
445        self.plan.functions.push(FunctionPlan {
446            id,
447            name: name.to_owned(),
448            span,
449            first: None,
450        });
451    }
452
453    fn strip_not(expr: &Expr) -> (&Expr, usize) {
454        let mut current = expr;
455        let mut depth = 0;
456        while let Expr::UnaryOp(unary) = current {
457            if unary.op != UnaryOp::Not {
458                break;
459            }
460            depth += 1;
461            current = &unary.operand;
462        }
463        (current, depth)
464    }
465
466    /// Flatten a test expression into leaves and a short-circuit tree. A
467    /// BoolOp is part of the tree only when it is the test itself or the
468    /// direct operand of another tree BoolOp; `not (a and b)` stays one leaf
469    /// because CPython inverts the jump senses inside it rather than exposing
470    /// the operands as separate decision conditions.
471    fn tree(
472        &mut self,
473        expr: &Expr,
474        leaves: &mut Vec<ConditionPlan>,
475        boolops: &mut Vec<(TextRange, Vec<Vec<usize>>)>,
476    ) -> Option<ConditionTree> {
477        let (operand, not) = Self::strip_not(expr);
478        if let Expr::BoolOp(boolean) = operand {
479            let mut items = Vec::with_capacity(boolean.values.len());
480            let mut groups = Vec::with_capacity(boolean.values.len());
481            for value in &boolean.values {
482                let first = leaves.len();
483                items.push(self.tree(value, leaves, boolops)?);
484                groups.push((first..leaves.len()).collect());
485            }
486            boolops.push((boolean.range, groups));
487            return Some(ConditionTree::Node {
488                op: match boolean.op {
489                    BoolOp::And => "and".into(),
490                    BoolOp::Or => "or".into(),
491                },
492                items,
493                negate: not % 2 == 1,
494            });
495        }
496        let span = self.span(operand.range())?;
497        leaves.push(ConditionPlan { span, not });
498        Some(ConditionTree::Leaf(leaves.len() - 1))
499    }
500
501    fn decision(&mut self, test: &Expr, kind: &str) {
502        let range = test.range();
503        let id = stable_id(self.file, "decision", range, kind);
504        if !self.decision_ids.insert(id.clone()) {
505            return;
506        }
507        let Some((line, column, source)) = self.location_source(range) else {
508            return;
509        };
510        let mut leaves = Vec::new();
511        let mut boolops = Vec::new();
512        if self.tree(test, &mut leaves, &mut boolops).is_none() {
513            return;
514        }
515        let mut conditions = Vec::with_capacity(leaves.len());
516        for leaf in &leaves {
517            let leaf_range = TextRange::new(
518                self.locations.line_starts[leaf.span.start[0] - 1]
519                    .saturating_add(leaf.span.start[1])
520                    .try_into()
521                    .unwrap_or_default(),
522                self.locations.line_starts[leaf.span.end[0] - 1]
523                    .saturating_add(leaf.span.end[1])
524                    .try_into()
525                    .unwrap_or_default(),
526            );
527            match self.locations.text(leaf_range) {
528                Ok(text) => conditions.push(if leaf.not % 2 == 1 {
529                    format!("not {text}")
530                } else {
531                    text
532                }),
533                Err(error) => {
534                    self.fail::<()>(error);
535                    return;
536                }
537            }
538        }
539        for (boolop_range, groups) in boolops {
540            let (start, end) = match self.locations.range(boolop_range) {
541                Ok(bounds) => bounds,
542                Err(error) => {
543                    self.fail::<()>(error);
544                    return;
545                }
546            };
547            self.decision_boolops
548                .insert((start, end), (id.clone(), groups));
549        }
550        self.manifest.decisions.push(DecisionMeta {
551            id: id.clone(),
552            file: self.file.into(),
553            line,
554            column,
555            source: source.clone(),
556            conditions,
557            kind: kind.into(),
558        });
559        let outcome_id = format!("{id}:outcome");
560        self.branch_with_id(
561            outcome_id.clone(),
562            range,
563            kind,
564            source,
565            [("true", "true"), ("false", "false")],
566        );
567        let Some(span) = self.span(range) else {
568            return;
569        };
570        self.plan.decisions.push(DecisionPlan {
571            id,
572            kind: kind.into(),
573            span,
574            conditions: leaves,
575            outcome_true: format!("{outcome_id}:true"),
576            outcome_false: format!("{outcome_id}:false"),
577            when_true: None,
578            when_false: None,
579        });
580    }
581
582    fn branch<const N: usize>(
583        &mut self,
584        range: TextRange,
585        kind: &str,
586        alternatives: [(&str, &str); N],
587    ) -> Option<String> {
588        let id = stable_id(self.file, "branch", range, kind);
589        let (_, _, source) = self.location_source(range)?;
590        self.branch_with_id(id, range, kind, source, alternatives)
591    }
592
593    fn branch_with_id<const N: usize>(
594        &mut self,
595        id: String,
596        range: TextRange,
597        kind: &str,
598        source: String,
599        alternatives: [(&str, &str); N],
600    ) -> Option<String> {
601        if !self.branch_ids.insert(id.clone()) {
602            return None;
603        }
604        let (line, column, _) = self.location_source(range)?;
605        self.manifest.branches.push(BranchMeta {
606            id: id.clone(),
607            kind: kind.into(),
608            file: self.file.into(),
609            line,
610            column,
611            source,
612            alternatives: alternatives
613                .into_iter()
614                .map(|(suffix, label)| BranchAlternativeMeta {
615                    id: format!("{id}:{suffix}"),
616                    label: label.into(),
617                })
618                .collect(),
619        });
620        Some(id)
621    }
622
623    fn loop_branch(&mut self, range: TextRange, iter: &Expr, kind: &str) {
624        let Some(id) = self.branch(
625            range,
626            kind,
627            [("zero", "zero iterations"), ("entered", "entered")],
628        ) else {
629            return;
630        };
631        let Some(iter_span) = self.span(iter.range()) else {
632            return;
633        };
634        self.plan.loops.push(LoopPlan {
635            zero: format!("{id}:zero"),
636            entered: format!("{id}:entered"),
637            id,
638            iter: iter_span,
639            first: None,
640        });
641    }
642
643    fn try_statement(&mut self, statement: &ruff_python_ast::StmtTry) {
644        let Some(id) = self.branch(
645            statement.range,
646            if statement.is_star { "try-star" } else { "try" },
647            [("success", "try completed"), ("raised", "handler entered")],
648        ) else {
649            return;
650        };
651        let body_range = match (statement.body.first(), statement.body.last()) {
652            (Some(first), Some(last)) => TextRange::new(first.start(), last.end()),
653            _ => return,
654        };
655        let Some(body) = self.span(body_range) else {
656            return;
657        };
658        let block_span = |collector: &mut Self, block: &[Stmt]| -> Option<Option<PlanSpan>> {
659            match (block.first(), block.last()) {
660                (Some(first), Some(last)) => collector
661                    .span(TextRange::new(first.start(), last.end()))
662                    .map(Some),
663                _ => Some(None),
664            }
665        };
666        let Some(finalbody) = block_span(self, &statement.finalbody) else {
667            return;
668        };
669        let mut handlers = Vec::with_capacity(statement.handlers.len());
670        for (index, handler) in statement.handlers.iter().enumerate() {
671            let Some(handler_id) = self.branch(
672                handler.range(),
673                &format!("except-{index}"),
674                [("missed", "not selected"), ("selected", "selected")],
675            ) else {
676                return;
677            };
678            let ruff_python_ast::ExceptHandler::ExceptHandler(clause) = handler;
679            handlers.push(HandlerPlan {
680                missed: format!("{handler_id}:missed"),
681                selected: format!("{handler_id}:selected"),
682                id: handler_id,
683                bare: clause.type_.is_none(),
684            });
685        }
686        self.plan.tries.push(TryPlan {
687            success: format!("{id}:success"),
688            raised: format!("{id}:raised"),
689            id,
690            body,
691            finalbody,
692            handlers,
693        });
694    }
695
696    fn logical(&mut self, boolean: &ruff_python_ast::ExprBoolOp) {
697        let Some(boolop_span) = self.span(boolean.range) else {
698            return;
699        };
700        let bounds = match self.locations.range(boolean.range) {
701            Ok(bounds) => bounds,
702            Err(error) => {
703                self.fail::<()>(error);
704                return;
705            }
706        };
707        let decision = self.decision_boolops.get(&bounds).cloned();
708        let op = match boolean.op {
709            BoolOp::And => "and",
710            BoolOp::Or => "or",
711        };
712        for (index, value) in boolean.values.iter().enumerate().skip(1) {
713            let Some(id) = self.branch(
714                value.range(),
715                &format!("logical-{op}-{index}"),
716                [
717                    ("short-circuit", "short-circuited"),
718                    ("evaluated", "right operand evaluated"),
719                ],
720            ) else {
721                continue;
722            };
723            let (decision_id, previous_leaves, operand_leaves) = match &decision {
724                Some((decision_id, groups)) => (
725                    Some(decision_id.clone()),
726                    Some(groups[index - 1].clone()),
727                    Some(groups[index].clone()),
728                ),
729                None => (None, None, None),
730            };
731            self.plan.logical.push(LogicalPlan {
732                short_circuit: format!("{id}:short-circuit"),
733                evaluated: format!("{id}:evaluated"),
734                id,
735                boolop: boolop_span,
736                operand: index,
737                decision: decision_id,
738                previous_leaves,
739                operand_leaves,
740            });
741        }
742    }
743
744    fn match_statement(&mut self, statement: &ruff_python_ast::StmtMatch) {
745        let Some(span) = self.span(statement.range) else {
746            return;
747        };
748        let mut cases = Vec::with_capacity(statement.cases.len());
749        for (index, case) in statement.cases.iter().enumerate() {
750            let kind = format!("match-case-{index}");
751            let Some(id) = self.branch(
752                case.range,
753                &kind,
754                [("missed", "not selected"), ("selected", "selected")],
755            ) else {
756                return;
757            };
758            if let Some(guard) = &case.guard {
759                self.decision(guard, "match-guard");
760            }
761            let Some(case_span) = self.span(case.range) else {
762                return;
763            };
764            cases.push(MatchCasePlan {
765                missed: format!("{id}:missed"),
766                selected: format!("{id}:selected"),
767                id,
768                span: case_span,
769                irrefutable: case.guard.is_none() && case.pattern.is_irrefutable(),
770            });
771        }
772        let no_case = if statement
773            .cases
774            .iter()
775            .any(|case| case.guard.is_none() && case.pattern.is_irrefutable())
776        {
777            None
778        } else {
779            self.branch(
780                statement.subject.range(),
781                "match-no-case",
782                [
783                    ("matched", "some case matched"),
784                    ("unmatched", "no case matched"),
785                ],
786            )
787            .map(|id| MatchNoCasePlan {
788                matched: format!("{id}:matched"),
789                unmatched: format!("{id}:unmatched"),
790                id,
791            })
792        };
793        self.plan.matches.push(MatchPlan {
794            span,
795            cases,
796            no_case,
797        });
798    }
799
800    /// The id of the first statement of `body` that executes: what the
801    /// transform's probe before that statement records.
802    fn first_statement(&self, body: &[Stmt]) -> Option<String> {
803        body.iter()
804            .enumerate()
805            .find(|(index, statement)| !is_unobservable_statement(statement, *index == 0))
806            .map(|(_, statement)| stable_id(self.file, "statement", statement.range(), ""))
807    }
808
809    /// Where a false test's branch starts: the next clause, whose first
810    /// statement is a head when it is an `else`; an `elif` starts with its
811    /// test, which is no statement.
812    fn else_head(&self, clauses: &[ruff_python_ast::ElifElseClause]) -> Option<String> {
813        clauses
814            .first()
815            .filter(|clause| clause.test.is_none())
816            .and_then(|clause| self.first_statement(&clause.body))
817    }
818
819    /// Name the statements that stand for the decision just planned for
820    /// `test`: the heads of the branches its outcomes take.
821    fn branch_heads(&mut self, test: &Expr, when_true: Option<String>, when_false: Option<String>) {
822        let Ok(span) = self.locations.span(test.range()) else {
823            return;
824        };
825        if let Some(plan) = self
826            .plan
827            .decisions
828            .iter_mut()
829            .rev()
830            .find(|plan| plan.span == span)
831        {
832            plan.when_true = when_true;
833            plan.when_false = when_false;
834        }
835    }
836
837    fn visit_body_statements(&mut self, body: &'a [Stmt]) {
838        for (index, statement) in body.iter().enumerate() {
839            if is_unobservable_statement(statement, index == 0) {
840                // Its expressions still may not contain obligations worth
841                // walking (docstrings, names), so skip entirely.
842                continue;
843            }
844            self.visit_stmt(statement);
845        }
846    }
847}
848
849impl<'a> Visitor<'a> for PythonObligationCollector<'a> {
850    fn visit_body(&mut self, body: &'a [Stmt]) {
851        self.visit_body_statements(body);
852    }
853
854    fn visit_stmt(&mut self, statement: &'a Stmt) {
855        self.statement(statement);
856        match statement {
857            Stmt::FunctionDef(function) => {
858                self.function(function.range, &function.name);
859                let first = self.first_statement(&function.body);
860                if let Some(plan) = self.plan.functions.last_mut()
861                    && plan.id == stable_id(self.file, "function", function.range, &function.name)
862                {
863                    plan.first = first;
864                }
865            }
866            Stmt::If(statement) => {
867                let heads = (
868                    self.first_statement(&statement.body),
869                    self.else_head(&statement.elif_else_clauses),
870                );
871                self.decision(&statement.test, "if");
872                self.branch_heads(&statement.test, heads.0, heads.1);
873                for (index, clause) in statement.elif_else_clauses.iter().enumerate() {
874                    if let Some(test) = &clause.test {
875                        let heads = (
876                            self.first_statement(&clause.body),
877                            self.else_head(&statement.elif_else_clauses[index + 1..]),
878                        );
879                        self.decision(test, "elif");
880                        self.branch_heads(test, heads.0, heads.1);
881                    }
882                }
883            }
884            Stmt::While(statement) => {
885                let heads = (
886                    self.first_statement(&statement.body),
887                    self.first_statement(&statement.orelse),
888                );
889                self.decision(&statement.test, "while");
890                self.branch_heads(&statement.test, heads.0, heads.1);
891            }
892            Stmt::For(statement) => {
893                self.loop_branch(
894                    statement.range,
895                    &statement.iter,
896                    if statement.is_async {
897                        "async-for"
898                    } else {
899                        "for"
900                    },
901                );
902                let first = self.first_statement(&statement.body);
903                if let (Some(plan), Ok(span)) = (
904                    self.plan.loops.last_mut(),
905                    self.locations.span(statement.iter.range()),
906                ) && plan.iter == span
907                {
908                    plan.first = first;
909                }
910            }
911            Stmt::Match(statement) => self.match_statement(statement),
912            Stmt::Try(statement) => self.try_statement(statement),
913            Stmt::Assert(statement) => {
914                self.decision(&statement.test, "assert");
915            }
916            _ => {}
917        }
918        walk_stmt(self, statement);
919    }
920
921    fn visit_expr(&mut self, expression: &'a Expr) {
922        match expression {
923            Expr::Lambda(lambda) => self.function(lambda.range, "<lambda>"),
924            Expr::If(expression) => {
925                self.decision(&expression.test, "ternary");
926            }
927            Expr::BoolOp(expression) => self.logical(expression),
928            _ => {}
929        }
930        walk_expr(self, expression);
931    }
932
933    fn visit_comprehension(&mut self, comprehension: &'a Comprehension) {
934        self.loop_branch(
935            comprehension.range,
936            &comprehension.iter,
937            if comprehension.is_async {
938                "async-comprehension"
939            } else {
940                "comprehension"
941            },
942        );
943        for condition in &comprehension.ifs {
944            self.decision(condition, "comprehension-if");
945        }
946        walk_comprehension(self, comprehension);
947    }
948}
949
950/// Build the complete obligation manifest and runtime probe plan for one
951/// Python source file. Limitations are per file; the run-level frontend
952/// deduplicates their IDs across files.
953pub fn build_python_obligations(
954    file: &str,
955    source: &str,
956) -> Result<PythonFileObligations, PythonInstrumenterError> {
957    if source.len() > u32::MAX as usize {
958        return Err(PythonInstrumenterError::SourceTooLarge);
959    }
960    let parsed =
961        parse_module(source).map_err(|error| PythonInstrumenterError::Parse(error.to_string()))?;
962    let mut collector = PythonObligationCollector::new(file, source);
963    collector.visit_body(parsed.suite());
964    if let Some(error) = collector.error {
965        return Err(error);
966    }
967    collector.manifest.unmeasured.sort();
968    collector.manifest.unmeasured.dedup();
969    Ok(PythonFileObligations {
970        manifest: collector.manifest,
971        plan: collector.plan,
972    })
973}
974
975/// Manifest-only view kept for callers that predate the probe plan.
976pub fn build_python_manifest(
977    file: &str,
978    source: &str,
979) -> Result<CoverageManifest, PythonInstrumenterError> {
980    build_python_obligations(file, source).map(|obligations| obligations.manifest)
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    const SOURCE: &str = r#"async def classify[T](items, flag=True):
988    values = [item async for item in items if item.ready and flag]
989    for value in values:
990        if value.primary and (value.safe or flag):
991            return value if flag else None
992        elif value.fallback:
993            break
994    try:
995        match values:
996            case [first, *_] if first.ready:
997                return first
998            case []:
999                return None
1000    except* ValueError:
1001        return None
1002    return (lambda value: value or flag)(None)
1003"#;
1004
1005    #[test]
1006    fn discovers_current_python_obligations_with_exact_ranges_and_stable_ids() {
1007        let first = build_python_obligations("src/app.py", SOURCE).unwrap();
1008        let second = build_python_obligations("src/app.py", SOURCE).unwrap();
1009        assert_eq!(first, second);
1010        let manifest = &first.manifest;
1011        assert!(manifest.points.iter().any(|point| {
1012            point.kind == PointKind::Function && point.label.as_deref() == Some("classify")
1013        }));
1014        assert!(manifest.points.iter().any(|point| {
1015            point.kind == PointKind::Function && point.label.as_deref() == Some("<lambda>")
1016        }));
1017        let if_decision = manifest
1018            .decisions
1019            .iter()
1020            .find(|decision| decision.kind == "if")
1021            .unwrap();
1022        assert_eq!(
1023            if_decision.conditions,
1024            ["value.primary", "value.safe", "flag"]
1025        );
1026        assert_eq!(if_decision.line, 4);
1027        assert_eq!(if_decision.column, 11);
1028        for kind in ["ternary", "match-guard", "comprehension-if", "elif"] {
1029            assert!(
1030                manifest
1031                    .decisions
1032                    .iter()
1033                    .any(|decision| decision.kind == kind),
1034                "missing {kind}"
1035            );
1036        }
1037        assert!(
1038            manifest
1039                .branches
1040                .iter()
1041                .any(|branch| branch.kind == "async-comprehension")
1042        );
1043        assert!(
1044            manifest
1045                .branches
1046                .iter()
1047                .any(|branch| branch.kind == "try-star")
1048        );
1049        assert!(
1050            manifest
1051                .branches
1052                .iter()
1053                .any(|branch| branch.kind.starts_with("logical-and"))
1054        );
1055        assert!(
1056            manifest
1057                .decisions
1058                .iter()
1059                .all(|decision| decision.id.starts_with("py:decision:"))
1060        );
1061        assert!(manifest.unmeasured.is_empty());
1062        assert!(manifest.limitations.is_empty());
1063    }
1064
1065    #[test]
1066    fn plan_carries_spans_polarity_and_logical_leaves() {
1067        let plan = build_python_obligations("src/app.py", SOURCE).unwrap().plan;
1068        let if_plan = plan
1069            .decisions
1070            .iter()
1071            .find(|decision| decision.kind == "if")
1072            .unwrap();
1073        assert_eq!(if_plan.conditions.len(), 3);
1074        assert_eq!(if_plan.conditions[0].span.start, [4, 11]);
1075        assert_eq!(if_plan.conditions[0].span.end, [4, 24]);
1076        assert_eq!(if_plan.conditions[1].span.start, [4, 30]);
1077        // `a and (b or c)`: the decision's BoolOps become logical branches
1078        // the runtime derives from the vector, each naming the conditions of
1079        // the operand before it and of its own.
1080        let mut in_decision = plan
1081            .logical
1082            .iter()
1083            .filter(|logical| logical.decision.as_deref() == Some(if_plan.id.as_str()))
1084            .map(|logical| {
1085                (
1086                    logical.previous_leaves.clone().unwrap(),
1087                    logical.operand_leaves.clone().unwrap(),
1088                )
1089            })
1090            .collect::<Vec<_>>();
1091        in_decision.sort();
1092        assert_eq!(in_decision, [(vec![0], vec![1, 2]), (vec![1], vec![2])]);
1093        // The lambda's `value or flag` is value context.
1094        assert!(
1095            plan.logical
1096                .iter()
1097                .any(|logical| logical.decision.is_none())
1098        );
1099        assert!(
1100            plan.decisions
1101                .iter()
1102                .any(|decision| decision.kind == "comprehension-if")
1103        );
1104        assert!(
1105            plan.statements
1106                .iter()
1107                .any(|statement| statement.start == [1, 0])
1108        );
1109        assert!(
1110            plan.functions
1111                .iter()
1112                .any(|function| function.name == "classify" && function.span.start[0] == 1)
1113        );
1114        assert_eq!(plan.loops.len(), 2);
1115        assert_eq!(plan.matches.len(), 1);
1116        assert_eq!(plan.matches[0].cases.len(), 2);
1117        assert!(plan.matches[0].no_case.is_some());
1118        assert_eq!(plan.tries.len(), 1);
1119        let try_plan = &plan.tries[0];
1120        assert_eq!(try_plan.body.start, [9, 8]);
1121        assert_eq!(try_plan.handlers.len(), 1);
1122        assert!(!try_plan.handlers[0].bare);
1123        assert!(try_plan.finalbody.is_none());
1124    }
1125
1126    #[test]
1127    fn not_polarity_and_same_line_statements_are_modelled() {
1128        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";
1129        let obligations = build_python_obligations("m.py", source).unwrap();
1130        let decision = &obligations.plan.decisions[0];
1131        // `not (a and b)` keeps `a` and `b` as separate conditions, each as
1132        // written; the negation applies to the node's result.
1133        assert_eq!(decision.conditions.len(), 2);
1134        assert_eq!(decision.conditions[0].not, 0);
1135        assert_eq!(obligations.manifest.decisions[0].conditions, ["a", "b"]);
1136        let negated_leaf = build_python_obligations(
1137            "n.py",
1138            "def g(a):
1139    if not a:
1140        return 1
1141",
1142        )
1143        .unwrap();
1144        assert_eq!(negated_leaf.plan.decisions[0].conditions[0].not, 1);
1145        assert_eq!(negated_leaf.manifest.decisions[0].conditions, ["not a"]);
1146        // `return 1` shares line 2 with the `if`, `y = 2` shares line 3 with
1147        // `x = 1`, and `h = ...` shares line 4 with `g = ...`: each is its own
1148        // statement, probed at its own start, and nothing is unmeasured.
1149        let starts = obligations
1150            .plan
1151            .statements
1152            .iter()
1153            .map(|statement| statement.start)
1154            .collect::<Vec<_>>();
1155        for shared in [[2, 22], [3, 11], [4, 19]] {
1156            assert!(starts.contains(&shared), "{shared:?} in {starts:?}");
1157        }
1158        assert!(obligations.manifest.unmeasured.is_empty());
1159        assert!(obligations.manifest.limitations.is_empty());
1160        let lambdas = obligations
1161            .plan
1162            .functions
1163            .iter()
1164            .filter(|function| function.name == "<lambda>")
1165            .collect::<Vec<_>>();
1166        assert_eq!(lambdas.len(), 2);
1167        assert_ne!(lambdas[0].span, lambdas[1].span);
1168    }
1169
1170    #[test]
1171    fn try_statements_carry_bodies_handlers_and_finally_spans() {
1172        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";
1173        let plan = build_python_obligations("t.py", source).unwrap().plan;
1174        let try_plan = &plan.tries[0];
1175        assert_eq!(
1176            try_plan.body,
1177            PlanSpan {
1178                start: [3, 8],
1179                end: [3, 18]
1180            }
1181        );
1182        assert_eq!(
1183            try_plan.finalbody,
1184            Some(PlanSpan {
1185                start: [11, 8],
1186                end: [11, 16]
1187            })
1188        );
1189        assert_eq!(try_plan.handlers.len(), 2);
1190        assert!(!try_plan.handlers[0].bare);
1191        assert!(try_plan.handlers[1].bare);
1192    }
1193
1194    #[test]
1195    fn docstrings_and_scope_declarations_are_not_statements() {
1196        let source = "\"\"\"module doc\"\"\"\nX = 1\ndef f():\n    \"\"\"doc\"\"\"\n    global X\n    pass\n";
1197        let obligations = build_python_obligations("m.py", source).unwrap();
1198        let statements = obligations
1199            .manifest
1200            .points
1201            .iter()
1202            .filter(|point| point.kind == PointKind::Statement)
1203            .map(|point| point.line)
1204            .collect::<Vec<_>>();
1205        assert_eq!(statements, [2, 3, 6]);
1206    }
1207
1208    #[test]
1209    fn each_structure_names_the_statement_that_heads_it() {
1210        // The statement whose execution implies an entry, an iteration or a
1211        // one-condition outcome: the first that executes in that body.
1212        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";
1213        let obligations = build_python_obligations("m.py", source).unwrap();
1214        let statement_on = |line: usize| {
1215            let start = obligations
1216                .plan
1217                .statements
1218                .iter()
1219                .find(|statement| statement.start[0] == line)
1220                .unwrap_or_else(|| panic!("a statement on line {line}"));
1221            Some(start.id.clone())
1222        };
1223        assert_eq!(obligations.plan.functions[0].first, statement_on(4));
1224        let decision = |line: usize| {
1225            obligations
1226                .plan
1227                .decisions
1228                .iter()
1229                .find(|decision| decision.span.start[0] == line)
1230                .unwrap()
1231        };
1232        // `if a` falls to an `elif`, whose test is no statement.
1233        assert_eq!(
1234            (
1235                decision(5).when_true.clone(),
1236                decision(5).when_false.clone()
1237            ),
1238            (statement_on(6), None)
1239        );
1240        assert_eq!(
1241            (
1242                decision(7).when_true.clone(),
1243                decision(7).when_false.clone()
1244            ),
1245            (statement_on(8), statement_on(10))
1246        );
1247        assert_eq!(
1248            (
1249                decision(11).when_true.clone(),
1250                decision(11).when_false.clone()
1251            ),
1252            (statement_on(12), statement_on(14))
1253        );
1254        assert_eq!(
1255            (
1256                decision(17).when_true.clone(),
1257                decision(17).when_false.clone()
1258            ),
1259            (statement_on(18), None)
1260        );
1261        assert_eq!(obligations.plan.loops[0].first, statement_on(16));
1262    }
1263
1264    #[test]
1265    fn rejects_invalid_python_without_partial_obligations() {
1266        assert!(matches!(
1267            build_python_manifest("src/broken.py", "if :\n    pass\n"),
1268            Err(PythonInstrumenterError::Parse(_))
1269        ));
1270    }
1271}