diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index 3613565a..6a1d0a89 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -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 @@ -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, @@ -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) diff --git a/src/mutmut/mutation/mutators.py b/src/mutmut/mutation/mutators.py index 40b074d9..9e5c5095 100644 --- a/src/mutmut/mutation/mutators.py +++ b/src/mutmut/mutation/mutators.py @@ -21,6 +21,20 @@ NON_ESCAPE_SEQUENCE = re.compile(r"((? 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)) @@ -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) :] @@ -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]: diff --git a/tests/e2e/test_e2e_coverage.py b/tests/e2e/test_e2e_coverage.py index bb1b2f98..ee00027c 100644 --- a/tests/e2e/test_e2e_coverage.py +++ b/tests/e2e/test_e2e_coverage.py @@ -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, diff --git a/tests/e2e/test_e2e_my_lib.py b/tests/e2e/test_e2e_my_lib.py index ec5263a2..f653d80f 100644 --- a/tests/e2e/test_e2e_my_lib.py +++ b/tests/e2e/test_e2e_my_lib.py @@ -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, diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 5db569d4..13cc5922 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -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"),