Skip to content

Commit 0136345

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 using the same mutation approach as SimpleString (no special whitespace handling). Concatenated strings are mutated through their individual string components. Fixes #439
1 parent b6c8b6b commit 0136345

File tree

2 files changed

+41
-0
lines changed

2 files changed

+41
-0
lines changed

mutmut/node_mutation.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,40 @@ 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+
old_value = part.value
65+
66+
supported_str_mutations: list[Callable[[str], str]] = [
67+
lambda x: "XX" + x + "XX",
68+
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), x),
69+
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), x),
70+
]
71+
72+
for mut_func in supported_str_mutations:
73+
new_value = mut_func(old_value)
74+
if new_value == old_value:
75+
continue
76+
77+
# Create a new part with the mutated value
78+
mutated_part = part.with_changes(value=new_value)
79+
# Replace the part in the parts list
80+
new_parts = [*node.parts[:i], mutated_part, *node.parts[i+1:]]
81+
yield node.with_changes(parts=new_parts)
82+
83+
# Only mutate the first non-empty text part
84+
break
85+
86+
elif isinstance(node, cst.ConcatenatedString):
87+
# ConcatenatedString nodes themselves don't need mutation; their SimpleString/FormattedString
88+
# children will be mutated directly by the mutation framework when it visits them.
89+
# Note: Currently, all parts of a concatenated string are mutated. Ideally, we would only
90+
# mutate the first non-empty part to reduce mutant count, but that would require changes
91+
# to the visitor pattern in file_mutation.py to skip certain children.
92+
return
93+
6094

6195
def operator_lambda(
6296
node: cst.Lambda

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}XX WorldXX"', '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)