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