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