Skip to content
Merged
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
37 changes: 32 additions & 5 deletions src/mutmut/mutation/mutators.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,17 @@
]
]

# pattern to match (nearly) all chars in a string that are not part of an escape sequence
NON_ESCAPE_SEQUENCE = re.compile(r"((?<!\\)[^\\]+)")
# Pattern matching either: (1) escape sequences with case-sensitive parts (preserved as group None),
# or (2) runs of non-backslash characters (captured as group 1 for case transformation).
# For non-raw strings, preserve \xHH, \uHHHH, \UHHHHHHHH, \N{NAME} to avoid equivalent mutants.
NON_ESCAPE_SEQUENCE = re.compile(
r"\\x[0-9a-fA-F]{2}" # \xHH
r"|\\u[0-9a-fA-F]{4}" # \uHHHH
r"|\\U[0-9a-fA-F]{8}" # \UHHHHHHHH
r"|\\N\{[^}]+\}" # \N{NAME}
r"|(\\.)" # other escapes like \\, \n, \t (group 1, but treat as escape)
r"|((?<!\\)[^\\]+)" # OR non-backslash run (group 2, to be transformed)
)


def operator_number(node: cst.BaseNumber) -> Iterable[cst.BaseNumber]:
Expand Down Expand Up @@ -53,13 +62,31 @@ def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]:
# that mutation is meaningless for
return

# For raw strings (r"..." or R"..."), escape sequences are literal, so case changes are NOT equivalent
is_raw = "r" in prefix.lower()

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),
]

if is_raw:
# Raw strings: use original regex (all non-backslash chars)
old_pattern = re.compile(r"((?<!\\)[^\\]+)")
supported_str_mutations.extend(
[
lambda x: old_pattern.sub(lambda m: m.group(1).lower(), x),
lambda x: old_pattern.sub(lambda m: m.group(1).upper(), x),
]
)
else:
# Regular strings: preserve escape sequences with case-sensitive hex/names
supported_str_mutations.extend(
[
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda m: m.group(2).lower() if m.group(2) else m.group(0), x),
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda m: m.group(2).upper() if m.group(2) else m.group(0), x),
]
)

for mut_func in supported_str_mutations:
new_value = f"{prefix}{value[0]}{mut_func(value[1:-1])}{value[-1]}"
if new_value == value:
Expand Down
1 change: 0 additions & 1 deletion tests/e2e/test_e2e_my_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ def test_my_lib_result_snapshot():
"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
10 changes: 9 additions & 1 deletion tests/mutation/test_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,19 @@ def mutated_module(source: str) -> str:
r'a: str = "Fo\N{ghost} \U11223344"',
[
r'a: str = "XXFo\N{ghost} \U11223344XX"',
r'a: str = "FO\N{GHOST} \U11223344"',
r'a: str = "FO\N{ghost} \U11223344"',
r'a: str = "fo\N{ghost} \U11223344"',
"a: str = None",
],
),
# Escape sequences with hex digits should not produce equivalent case-flip mutants
(r'"\u00a0"', [r'"XX\u00a0XX"']),
(r'"\U0001f600"', [r'"XX\U0001f600XX"']),
(r'"\x7f"', [r'"XX\x7fXX"']),
(r'"\N{GREEK CAPITAL LETTER DELTA}"', [r'"XX\N{GREEK CAPITAL LETTER DELTA}XX"']),
(r'"Hello\u00a0World"', [r'"XXHello\u00a0WorldXX"', r'"hello\u00a0world"', r'"HELLO\u00a0WORLD"']),
# Raw strings: escapes are literal, so case changes are NOT equivalent
(r'r"\u00a0"', [r'r"XX\u00a0XX"', r'r"\u00A0"']),
("lambda: 0", ["lambda: 1", "lambda: None"]),
("1 in (1, 2)", ["2 in (1, 2)", "1 not in (1, 2)", "1 in (2, 2)", "1 in (1, 3)"]),
("1+1", ["2+1", "1 - 1", "1+2"]),
Expand Down
Loading