Skip to content
Closed
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
34 changes: 34 additions & 0 deletions mutmut/node_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ def operator_string(
continue
yield node.with_changes(value=new_value)

elif isinstance(node, cst.FormattedString):
# Mutate the first non-empty FormattedStringText part in f-strings
for i, part in enumerate(node.parts):
if isinstance(part, cst.FormattedStringText) and part.value.strip():
old_value = part.value

supported_str_mutations: list[Callable[[str], str]] = [
lambda x: "XX" + x + "XX",
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(old_value)
if new_value == old_value:
continue

# Create a new part with the mutated value
mutated_part = part.with_changes(value=new_value)
# Replace the part in the parts list
new_parts = [*node.parts[:i], mutated_part, *node.parts[i+1:]]
yield node.with_changes(parts=new_parts)

# Only mutate the first non-empty text part
break

elif isinstance(node, cst.ConcatenatedString):
# ConcatenatedString nodes themselves don't need mutation; their SimpleString/FormattedString
# children will be mutated directly by the mutation framework when it visits them.
# Note: Currently, all parts of a concatenated string are mutated. Ideally, we would only
# mutate the first non-empty part to reduce mutant count, but that would require changes
# to the visitor pattern in file_mutation.py to skip certain children.
return


def operator_lambda(
node: cst.Lambda
Expand Down
7 changes: 7 additions & 0 deletions tests/test_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ def mutated_module(source: str) -> str:
('"FoO"', ['"XXFoOXX"', '"foo"', '"FOO"']),
("'FoO'", ["'XXFoOXX'", "'foo'", "'FOO'"]),
("u'FoO'", ["u'XXFoOXX'", "u'foo'", "u'FOO'"]),
# f-strings - mutate the first non-empty text part
('f"Hello {name}"', ['f"XXHello XX{name}"', 'f"hello {name}"', 'f"HELLO {name}"']),
('f"Hello {x} World"', ['f"XXHello XX{x} World"', 'f"hello {x} World"', 'f"HELLO {x} World"']),
('f"{x} World"', ['f"{x}XX WorldXX"', 'f"{x} world"', 'f"{x} WORLD"']),
('f"{x}{y}"', []), # no text parts to mutate
# concatenated strings - currently mutates all parts
('"Hello " "World"', ['"XXHello XX" "World"', '"hello " "World"', '"HELLO " "World"', '"Hello " "XXWorldXX"', '"Hello " "world"', '"Hello " "WORLD"']),
("10", "11"),
("10.", "11.0"),
("0o10", "9"),
Expand Down