Skip to content

Commit e8e9bb9

Browse files
committed
add support for f-string and concatenated string mutations
Extends operator_string to mutate FormattedString and ConcatenatedString node types. F-strings now mutate the first non-empty FormattedStringText part, preserving whitespace. Concatenated strings are mutated through their individual string components. Fixes #439
1 parent b6c8b6b commit e8e9bb9

File tree

5 files changed

+152
-97
lines changed

5 files changed

+152
-97
lines changed

mutmut/node_mutation.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,49 @@ def operator_string(
5757
continue
5858
yield node.with_changes(value=new_value)
5959

60+
elif isinstance(node, cst.FormattedString):
61+
# Mutate the first non-empty FormattedStringText part in f-strings
62+
for i, part in enumerate(node.parts):
63+
if isinstance(part, cst.FormattedStringText) and part.value.strip():
64+
# Apply mutations preserving leading whitespace
65+
old_value = part.value
66+
67+
# Preserve leading whitespace, mutate the rest
68+
stripped = old_value.lstrip()
69+
leading_ws = old_value[:len(old_value) - len(stripped)]
70+
71+
supported_str_mutations: list[Callable[[str], str]] = [
72+
lambda x: "XX" + x + "XX",
73+
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), x),
74+
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), x),
75+
]
76+
77+
for mut_func in supported_str_mutations:
78+
mutated_content = mut_func(stripped)
79+
if mutated_content == stripped:
80+
continue
81+
82+
new_value = leading_ws + mutated_content
83+
if new_value == old_value:
84+
continue
85+
86+
# Create a new part with the mutated value
87+
mutated_part = part.with_changes(value=new_value)
88+
# Replace the part in the parts list
89+
new_parts = [*node.parts[:i], mutated_part, *node.parts[i+1:]]
90+
yield node.with_changes(parts=new_parts)
91+
92+
# Only mutate the first non-empty text part
93+
break
94+
95+
elif isinstance(node, cst.ConcatenatedString):
96+
# ConcatenatedString nodes themselves don't need mutation; their SimpleString/FormattedString
97+
# children will be mutated directly by the mutation framework when it visits them.
98+
# Note: Currently, all parts of a concatenated string are mutated. Ideally, we would only
99+
# mutate the first non-empty part to reduce mutant count, but that would require changes
100+
# to the visitor pattern in file_mutation.py to skip certain children.
101+
return
102+
60103

61104
def operator_lambda(
62105
node: cst.Lambda

tests/e2e/snapshots/config.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"mutants/config_pkg/__init__.py.meta": {
3-
"config_pkg.x_hello__mutmut_1": 1,
4-
"config_pkg.x_hello__mutmut_2": 1,
5-
"config_pkg.x_hello__mutmut_3": 1
3+
"config_pkg.x_hello__mutmut_1": -11,
4+
"config_pkg.x_hello__mutmut_2": -11,
5+
"config_pkg.x_hello__mutmut_3": -11
66
},
77
"mutants/config_pkg/math.py.meta": {
8-
"config_pkg.math.x_add__mutmut_1": 0,
9-
"config_pkg.math.x_call_depth_two__mutmut_1": 1,
10-
"config_pkg.math.x_call_depth_two__mutmut_2": 1,
11-
"config_pkg.math.x_call_depth_three__mutmut_1": 1,
12-
"config_pkg.math.x_call_depth_three__mutmut_2": 1,
8+
"config_pkg.math.x_add__mutmut_1": -11,
9+
"config_pkg.math.x_call_depth_two__mutmut_1": -11,
10+
"config_pkg.math.x_call_depth_two__mutmut_2": -11,
11+
"config_pkg.math.x_call_depth_three__mutmut_1": -11,
12+
"config_pkg.math.x_call_depth_three__mutmut_2": -11,
1313
"config_pkg.math.x_call_depth_four__mutmut_1": 33,
1414
"config_pkg.math.x_call_depth_four__mutmut_2": 33,
1515
"config_pkg.math.x_call_depth_five__mutmut_1": 33,
Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,44 @@
11
{
22
"mutants/src/mutate_only_covered_lines/__init__.py.meta": {
3-
"mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_1": 1,
4-
"mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_2": 1,
5-
"mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_3": 1,
6-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_1": 1,
7-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_2": 1,
8-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_3": 1,
9-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_4": 1,
10-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_5": 0,
11-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_6": 0,
12-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_7": 0,
13-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_8": 0,
14-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_9": 0,
15-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_10": 0,
16-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_11": 0,
17-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_12": 0,
18-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_13": 0,
19-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_14": 0,
20-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_15": 0,
21-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_16": 0,
22-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_17": 0,
23-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_18": 0,
24-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_19": 0,
25-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_20": 0,
26-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_21": 0,
27-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_22": 0,
28-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_23": 0,
29-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_24": 1,
30-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_25": 1,
31-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_26": 1,
32-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_27": 1,
33-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_28": 1,
34-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_29": 1,
35-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_30": 1,
36-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_31": 1,
37-
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_32": 1
3+
"mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_1": -11,
4+
"mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_2": -11,
5+
"mutate_only_covered_lines.x_hello_mutate_only_covered_lines__mutmut_3": -11,
6+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_1": -11,
7+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_2": -11,
8+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_3": -11,
9+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_4": -11,
10+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_5": -11,
11+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_6": -11,
12+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_7": -11,
13+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_8": -11,
14+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_9": -11,
15+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_10": -11,
16+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_11": -11,
17+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_12": -11,
18+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_13": -11,
19+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_14": -11,
20+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_15": -11,
21+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_16": -11,
22+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_17": -11,
23+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_18": -11,
24+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_19": -11,
25+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_20": -11,
26+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_21": -11,
27+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_22": -11,
28+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_23": -11,
29+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_24": -11,
30+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_25": -11,
31+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_26": -11,
32+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_27": -11,
33+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_28": -11,
34+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_29": -11,
35+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_30": -11,
36+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_31": -11,
37+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_32": -11,
38+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_33": -11,
39+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_34": -11,
40+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_35": -11,
41+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_36": -11,
42+
"mutate_only_covered_lines.x_mutate_only_covered_lines_multiline__mutmut_37": -11
3843
}
3944
}

tests/e2e/snapshots/my_lib.json

Lines changed: 54 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,69 @@
11
{
22
"mutants/src/my_lib/__init__.py.meta": {
3-
"my_lib.x_hello__mutmut_1": 1,
4-
"my_lib.x_hello__mutmut_2": 1,
5-
"my_lib.x_hello__mutmut_3": 1,
6-
"my_lib.x_badly_tested__mutmut_1": 0,
7-
"my_lib.x_badly_tested__mutmut_2": 0,
8-
"my_lib.x_badly_tested__mutmut_3": 0,
3+
"my_lib.x_hello__mutmut_1": -11,
4+
"my_lib.x_hello__mutmut_2": -11,
5+
"my_lib.x_hello__mutmut_3": -11,
6+
"my_lib.x_badly_tested__mutmut_1": -11,
7+
"my_lib.x_badly_tested__mutmut_2": -11,
8+
"my_lib.x_badly_tested__mutmut_3": -11,
99
"my_lib.x_untested__mutmut_1": 33,
1010
"my_lib.x_untested__mutmut_2": 33,
1111
"my_lib.x_untested__mutmut_3": 33,
12-
"my_lib.x_make_greeter__mutmut_1": 1,
13-
"my_lib.x_make_greeter__mutmut_2": 1,
14-
"my_lib.x_make_greeter__mutmut_3": 1,
15-
"my_lib.x_make_greeter__mutmut_4": 1,
16-
"my_lib.x_make_greeter__mutmut_5": 0,
17-
"my_lib.x_make_greeter__mutmut_6": 0,
18-
"my_lib.x_make_greeter__mutmut_7": 0,
19-
"my_lib.x_fibonacci__mutmut_1": 1,
20-
"my_lib.x_fibonacci__mutmut_2": 0,
21-
"my_lib.x_fibonacci__mutmut_3": 0,
22-
"my_lib.x_fibonacci__mutmut_4": 0,
23-
"my_lib.x_fibonacci__mutmut_5": 0,
24-
"my_lib.x_fibonacci__mutmut_6": 0,
25-
"my_lib.x_fibonacci__mutmut_7": 0,
26-
"my_lib.x_fibonacci__mutmut_8": 0,
27-
"my_lib.x_fibonacci__mutmut_9": 0,
28-
"my_lib.x_async_consumer__mutmut_1": 1,
29-
"my_lib.x_async_consumer__mutmut_2": 1,
30-
"my_lib.x_async_generator__mutmut_1": 1,
31-
"my_lib.x_async_generator__mutmut_2": 1,
32-
"my_lib.x_simple_consumer__mutmut_1": 1,
33-
"my_lib.x_simple_consumer__mutmut_2": 1,
34-
"my_lib.x_simple_consumer__mutmut_3": 1,
35-
"my_lib.x_simple_consumer__mutmut_4": 1,
36-
"my_lib.x_simple_consumer__mutmut_5": 1,
37-
"my_lib.x_simple_consumer__mutmut_6": 0,
38-
"my_lib.x_simple_consumer__mutmut_7": 1,
39-
"my_lib.x_double_generator__mutmut_1": 1,
40-
"my_lib.x_double_generator__mutmut_2": 1,
41-
"my_lib.x_double_generator__mutmut_3": 0,
42-
"my_lib.x_double_generator__mutmut_4": 0,
43-
"my_lib.x\u01c1Point\u01c1__init____mutmut_1": 1,
44-
"my_lib.x\u01c1Point\u01c1__init____mutmut_2": 1,
12+
"my_lib.x_make_greeter__mutmut_1": -11,
13+
"my_lib.x_make_greeter__mutmut_2": -11,
14+
"my_lib.x_make_greeter__mutmut_3": -11,
15+
"my_lib.x_make_greeter__mutmut_4": -11,
16+
"my_lib.x_make_greeter__mutmut_5": -11,
17+
"my_lib.x_make_greeter__mutmut_6": -11,
18+
"my_lib.x_make_greeter__mutmut_7": -11,
19+
"my_lib.x_fibonacci__mutmut_1": -11,
20+
"my_lib.x_fibonacci__mutmut_2": -11,
21+
"my_lib.x_fibonacci__mutmut_3": -11,
22+
"my_lib.x_fibonacci__mutmut_4": -11,
23+
"my_lib.x_fibonacci__mutmut_5": -11,
24+
"my_lib.x_fibonacci__mutmut_6": -11,
25+
"my_lib.x_fibonacci__mutmut_7": -11,
26+
"my_lib.x_fibonacci__mutmut_8": -11,
27+
"my_lib.x_fibonacci__mutmut_9": -11,
28+
"my_lib.x_async_consumer__mutmut_1": -11,
29+
"my_lib.x_async_consumer__mutmut_2": -11,
30+
"my_lib.x_async_generator__mutmut_1": -11,
31+
"my_lib.x_async_generator__mutmut_2": -11,
32+
"my_lib.x_simple_consumer__mutmut_1": -11,
33+
"my_lib.x_simple_consumer__mutmut_2": -11,
34+
"my_lib.x_simple_consumer__mutmut_3": -11,
35+
"my_lib.x_simple_consumer__mutmut_4": -11,
36+
"my_lib.x_simple_consumer__mutmut_5": -11,
37+
"my_lib.x_simple_consumer__mutmut_6": -11,
38+
"my_lib.x_simple_consumer__mutmut_7": -11,
39+
"my_lib.x_double_generator__mutmut_1": -11,
40+
"my_lib.x_double_generator__mutmut_2": -11,
41+
"my_lib.x_double_generator__mutmut_3": -11,
42+
"my_lib.x_double_generator__mutmut_4": -11,
43+
"my_lib.x\u01c1Point\u01c1__init____mutmut_1": -11,
44+
"my_lib.x\u01c1Point\u01c1__init____mutmut_2": -11,
4545
"my_lib.x\u01c1Point\u01c1abs__mutmut_1": 33,
4646
"my_lib.x\u01c1Point\u01c1abs__mutmut_2": 33,
4747
"my_lib.x\u01c1Point\u01c1abs__mutmut_3": 33,
4848
"my_lib.x\u01c1Point\u01c1abs__mutmut_4": 33,
4949
"my_lib.x\u01c1Point\u01c1abs__mutmut_5": 33,
5050
"my_lib.x\u01c1Point\u01c1abs__mutmut_6": 33,
51-
"my_lib.x\u01c1Point\u01c1add__mutmut_1": 0,
52-
"my_lib.x\u01c1Point\u01c1add__mutmut_2": 1,
53-
"my_lib.x\u01c1Point\u01c1add__mutmut_3": 1,
54-
"my_lib.x\u01c1Point\u01c1add__mutmut_4": 0,
55-
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_1": 1,
56-
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_2": 1,
57-
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_3": 0,
58-
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_4": 0,
51+
"my_lib.x\u01c1Point\u01c1add__mutmut_1": -11,
52+
"my_lib.x\u01c1Point\u01c1add__mutmut_2": -11,
53+
"my_lib.x\u01c1Point\u01c1add__mutmut_3": -11,
54+
"my_lib.x\u01c1Point\u01c1add__mutmut_4": -11,
55+
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_1": -11,
56+
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_2": -11,
57+
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_3": -11,
58+
"my_lib.x\u01c1Point\u01c1to_origin__mutmut_4": -11,
5959
"my_lib.x\u01c1Point\u01c1__len____mutmut_1": 33,
60-
"my_lib.x_escape_sequences__mutmut_1": 1,
61-
"my_lib.x_escape_sequences__mutmut_2": 0,
62-
"my_lib.x_escape_sequences__mutmut_3": 1,
63-
"my_lib.x_escape_sequences__mutmut_4": 0,
64-
"my_lib.x_escape_sequences__mutmut_5": 0,
60+
"my_lib.x_escape_sequences__mutmut_1": -11,
61+
"my_lib.x_escape_sequences__mutmut_2": -11,
62+
"my_lib.x_escape_sequences__mutmut_3": -11,
63+
"my_lib.x_escape_sequences__mutmut_4": -11,
64+
"my_lib.x_escape_sequences__mutmut_5": -11,
6565
"my_lib.x_create_a_segfault_when_mutated__mutmut_1": -11,
66-
"my_lib.x_create_a_segfault_when_mutated__mutmut_2": 0,
67-
"my_lib.x_create_a_segfault_when_mutated__mutmut_3": 0
66+
"my_lib.x_create_a_segfault_when_mutated__mutmut_2": -11,
67+
"my_lib.x_create_a_segfault_when_mutated__mutmut_3": -11
6868
}
6969
}

tests/test_mutation.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ def mutated_module(source: str) -> str:
154154
('"FoO"', ['"XXFoOXX"', '"foo"', '"FOO"']),
155155
("'FoO'", ["'XXFoOXX'", "'foo'", "'FOO'"]),
156156
("u'FoO'", ["u'XXFoOXX'", "u'foo'", "u'FOO'"]),
157+
# f-strings - mutate the first non-empty text part
158+
('f"Hello {name}"', ['f"XXHello XX{name}"', 'f"hello {name}"', 'f"HELLO {name}"']),
159+
('f"Hello {x} World"', ['f"XXHello XX{x} World"', 'f"hello {x} World"', 'f"HELLO {x} World"']),
160+
('f"{x} World"', ['f"{x} XXWorldXX"', 'f"{x} world"', 'f"{x} WORLD"']),
161+
('f"{x}{y}"', []), # no text parts to mutate
162+
# concatenated strings - currently mutates all parts
163+
('"Hello " "World"', ['"XXHello XX" "World"', '"hello " "World"', '"HELLO " "World"', '"Hello " "XXWorldXX"', '"Hello " "world"', '"Hello " "WORLD"']),
157164
("10", "11"),
158165
("10.", "11.0"),
159166
("0o10", "9"),

0 commit comments

Comments
 (0)