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 },
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 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 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 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 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 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
950pub 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
975pub 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 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 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 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 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 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 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}