From 5a342f7661f45943bbb9e606dbe4a864ec4d6163 Mon Sep 17 00:00:00 2001 From: GhostCoder6969 Date: Wed, 30 Sep 2026 02:58:09 +0000 Subject: [PATCH] Skip -/+ swaps for unary ops in match-case patterns --- src/mutmut/mutation/file_mutation.py | 27 +++++++++++++++++++++++++++ tests/mutation/test_mutation.py | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index 3613565a..b73e3cd0 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -23,6 +23,7 @@ from mutmut.mutation.data import LineSpan from mutmut.mutation.mutators import OPERATORS_TYPE from mutmut.mutation.mutators import mutation_operators +from mutmut.mutation.mutators import operator_swap_op from mutmut.mutation.pragma_handling import IgnoredCode from mutmut.mutation.pragma_handling import get_ignored_lines from mutmut.mutation.trampoline_templates import build_mutants_dict_and_name @@ -188,6 +189,22 @@ def on_visit(self, node: cst.CSTNode) -> bool: return True +class _MatchPatternUnaryCollector(cst.CSTVisitor): + """Collect unary operations inside a match-case pattern. + + Flipping `-`/`+` there can produce invalid syntax (e.g. `case -2` + becomes `case +2`), so those nodes are skipped by the mutators.""" + + def __init__(self) -> None: + super().__init__() + self.nodes: list[cst.UnaryOperation] = [] + + def on_visit(self, node: cst.CSTNode) -> bool: + if isinstance(node, cst.UnaryOperation): + self.nodes.append(node) + return True + + class MutationVisitor(cst.CSTVisitor): """Iterate through all nodes in the module and create mutations for them. @@ -217,11 +234,17 @@ def __init__( ) self.ignored_classes: set[str] = set() self.ignored_functions: set[str] = set() + self._match_pattern_unary_ops: set[int] = set() def on_visit(self, node: cst.CSTNode) -> bool: if self._skip_node_and_children(node): return False + if isinstance(node, cst.MatchCase): + collector = _MatchPatternUnaryCollector() + node.pattern.visit(collector) + self._match_pattern_unary_ops.update(map(id, collector.nodes)) + if self._should_mutate_node(node): self._create_mutations(node) @@ -231,6 +254,10 @@ def on_visit(self, node: cst.CSTNode) -> bool: def _create_mutations(self, node: cst.CSTNode) -> None: for t, operator in self._operators: if isinstance(node, t): + if operator is operator_swap_op and id(node) in self._match_pattern_unary_ops: + # Swapping -/+ inside a match-case pattern can produce + # invalid syntax (e.g. `case -2` becomes `case +2`). + continue for mutated_node in operator(node): if self._removes_ignored_code(node, mutated_node): continue diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 0ff0d198..d513fc3a 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -412,6 +412,26 @@ def concat(): assert sorted(mutants) == [] +def test_match_case_does_not_flip_unary_sign(): + # Flipping `-` to `+` inside a match-case pattern produces `case +2`, + # which is a syntax error (see #583). + source = """ +match x: + case -2: + y = 1 + case _: + y = 2""".strip() + + mutants = mutants_for_source(source) + + assert not any("case +2" in mutant for mutant in mutants) + # Every generated mutant must still be valid syntax. + for mutant in mutants: + compile(mutant, "", "exec") + # Other mutants (case drop, number bump, body changes) are unaffected. + assert any("case -3" in mutant for mutant in mutants) + + def test_basic_class(): source = """ class Foo: