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