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