Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/mutmut/mutation/file_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import libcst as cst
import libcst.matchers as m
from libcst.metadata import MetadataWrapper
from libcst.metadata import ParentNodeProvider
from libcst.metadata import PositionProvider

from mutmut.configuration import config
Expand Down Expand Up @@ -198,7 +199,7 @@ class MutationVisitor(cst.CSTVisitor):

The created mutations will be accessible at `self.mutations`."""

METADATA_DEPENDENCIES = (PositionProvider, OuterFunctionProvider)
METADATA_DEPENDENCIES = (PositionProvider, ParentNodeProvider, OuterFunctionProvider)

def __init__(
self,
Expand Down Expand Up @@ -268,6 +269,14 @@ def _spans_ignored_line(self, node: cst.CSTNode) -> bool:
return any(line in self._all_ignored_lines for line in range(position.start.line, position.end.line + 1))

def _should_mutate_node(self, node: cst.CSTNode) -> bool:
# Concatenated strings are mutated as one unit by their outermost node.
# Mutating their child strings separately would create duplicate mutants
# and mutate more than one string part in the concatenation.
if isinstance(node, cst.BaseString) and isinstance(
self.get_metadata(ParentNodeProvider, node, None), cst.ConcatenatedString
):
return False

# currently, the position metadata does not always exist
# (see https://github.com/Instagram/LibCST/issues/1322)
position = self.get_metadata(PositionProvider, node, None)
Expand Down
57 changes: 43 additions & 14 deletions src/mutmut/mutation/mutators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
NON_ESCAPE_SEQUENCE = re.compile(r"((?<!\\)[^\\]+)")


def _mutated_string_contents(value: str) -> Iterable[str]:
supported_str_mutations: list[Callable[[str], str]] = [
lambda x: "XX" + x + "XX",
# do not modify escape sequences, as this could break python syntax
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), x),
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), x),
]

for mut_func in supported_str_mutations:
new_value = mut_func(value)
if new_value != value:
yield new_value


def operator_number(node: cst.BaseNumber) -> Iterable[cst.BaseNumber]:
if isinstance(node, cst.Integer | cst.Float):
yield node.with_changes(value=repr(node.evaluated_value + 1))
Expand All @@ -30,10 +44,9 @@ def operator_number(node: cst.BaseNumber) -> Iterable[cst.BaseNumber]:
print("Unexpected number type", node)


def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]:
def _operator_string(node: cst.BaseString, *, skip_empty: bool) -> Iterable[cst.BaseString]:
if isinstance(node, cst.SimpleString):
value = node.value
old_value = value
prefix = value[: min([x for x in [value.find('"'), value.find("'")] if x != -1])]
value = value[len(prefix) :]

Expand All @@ -42,20 +55,36 @@ def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]:
# that mutation is meaningless for
return

supported_str_mutations: list[Callable[[str], str]] = [
lambda x: "XX" + x + "XX",
# do not modify escape sequences, as this could break python syntax
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), x),
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), x),
]
contents = value[1:-1]
if skip_empty and not contents:
return

for mut_func in supported_str_mutations:
new_value = f"{prefix}{value[0]}{mut_func(value[1:-1])}{value[-1]}"
if new_value == value:
continue
if new_value == old_value:
for mutated_contents in _mutated_string_contents(contents):
yield node.with_changes(value=f"{prefix}{value[0]}{mutated_contents}{value[-1]}")

elif isinstance(node, cst.FormattedString):
for index, part in enumerate(node.parts):
if not isinstance(part, cst.FormattedStringText) or not part.value:
continue
yield node.with_changes(value=new_value)

for mutated_value in _mutated_string_contents(part.value):
mutated_part = part.with_changes(value=mutated_value)
yield node.with_changes(parts=[*node.parts[:index], mutated_part, *node.parts[index + 1 :]])
return

elif isinstance(node, cst.ConcatenatedString):
left_mutations = list(_operator_string(node.left, skip_empty=True))
if left_mutations:
for mutated_left in left_mutations:
yield node.with_changes(left=mutated_left)
return

for mutated_right in _operator_string(node.right, skip_empty=True):
yield node.with_changes(right=mutated_right)


def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]:
yield from _operator_string(node, skip_empty=False)


def operator_lambda(node: cst.Lambda) -> Iterable[cst.Lambda]:
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/test_e2e_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ def test_mutate_only_covered_lines_result_snapshot():
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_30": 1,
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_31": 1,
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_32": 1,
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_33": 1,
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_34": 1,
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_35": 1,
},
"mutants/src/mutate_only_covered_lines/exclude_me.py.meta": {
"mutate_only_covered_lines.exclude_me.x_excluded_branch__mutmut_1": 1,
Expand Down
3 changes: 0 additions & 3 deletions tests/e2e/test_e2e_my_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,6 @@ def test_my_lib_result_snapshot():
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_8": 1,
"my_lib.x_escape_sequences__mutmut_1": 1,
"my_lib.x_escape_sequences__mutmut_2": 0,
"my_lib.x_escape_sequences__mutmut_3": 1,
"my_lib.x_escape_sequences__mutmut_4": 0,
"my_lib.x_escape_sequences__mutmut_5": 0,
"my_lib.x_create_a_segfault_when_mutated__mutmut_1": -11,
"my_lib.x_create_a_segfault_when_mutated__mutmut_2": 0,
"my_lib.x_create_a_segfault_when_mutated__mutmut_3": 0,
Expand Down
15 changes: 15 additions & 0 deletions tests/mutation/test_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ def mutated_module(source: str) -> str:
('"FoO"', ['"XXFoOXX"', '"foo"', '"FOO"']),
("'FoO'", ["'XXFoOXX'", "'foo'", "'FOO'"]),
("u'FoO'", ["u'XXFoOXX'", "u'foo'", "u'FOO'"]),
(
'f"FoO {name} ignored"',
['f"XXFoO XX{name} ignored"', 'f"foo {name} ignored"', 'f"FOO {name} ignored"'],
),
('f"{name} FoO"', ['f"{name}XX FoOXX"', 'f"{name} foo"', 'f"{name} FOO"']),
('f"{name}"', []),
(
'"FoO " "ignored"',
['"XXFoO XX" "ignored"', '"foo " "ignored"', '"FOO " "ignored"'],
),
('"" "FoO"', ['"" "XXFoOXX"', '"" "foo"', '"" "FOO"']),
(
'f"FoO {name}" "ignored"',
['f"XXFoO XX{name}" "ignored"', 'f"foo {name}" "ignored"', 'f"FOO {name}" "ignored"'],
),
("10", "11"),
("10.", "11.0"),
("0o10", "9"),
Expand Down
Loading