Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ Sometimes the mutated code needs to be built with a more complicated command tha

Working with something like maven is very similar, except you can probably edit the complicated build/clean stuff to just a 'mvn test' or similar.

RULE FILES
==========

Rules live in `.rules` files (see `universalmutator/static/` for the built-in
set). Each rule has the form `<pattern> ==> <replacement>`, where `<pattern>`
is a Python regular expression (or a [Comby](https://github.com/comby-tools/comby)
template in `--comby` mode). Two special right-hand sides are recognised:
`DO_NOT_MUTATE` marks matching lines as not-to-be-mutated, and
`SKIP_MUTATING_REST` tells the mutator to skip everything on a line past the
match (useful for in-source comments).

Lines beginning with `#` (optionally indented) are treated as comments, and
blank lines are ignored. You can use this to document groups of related rules
or temporarily disable a rule without deleting it:

```
# Arithmetic operator swaps
\+ ==> -
\+ ==> *

# Temporarily disabled while investigating false positives:
# && ==> ||
```

CURRENTLY SUPPORTED LANGUAGES
=============================

Expand Down
179 changes: 179 additions & 0 deletions tests/test_rule_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Tests for comment and blank-line handling in rule files (issue #23).

These tests verify parseRules() correctly:
* treats lines beginning with '#' (optionally indented) as comments
* skips blank and whitespace-only lines without warning
* continues to parse existing rules whose LHS starts with '#'
(e.g. '#include ==> DO_NOT_MUTATE', '# ==> SKIP_MUTATING_REST')
* still warns on genuinely malformed rule lines
"""
from __future__ import print_function

import io
import os
import sys
import tempfile
import unittest
import shutil
import subprocess

from universalmutator.mutator import parseRules

#confirm comby installation
HAS_COMBY = shutil.which("comby") is not None

class TestRuleComments(unittest.TestCase):

def _parse(self, rule_text):
"""Write rule_text to a temp .rules file, call parseRules, return
(rules, ignoreRules, skipRules, captured_stdout)."""
fd, path = tempfile.mkstemp(suffix=".rules")
os.close(fd)
try:
with open(path, "w") as f:
f.write(rule_text)
buf = io.StringIO()
orig_stdout = sys.stdout
sys.stdout = buf
try:
rules, ignoreRules, skipRules = parseRules([path])
finally:
sys.stdout = orig_stdout
return rules, ignoreRules, skipRules, buf.getvalue()
finally:
os.unlink(path)

def assertNoMalformedWarning(self, output):
self.assertNotIn(
"DOES NOT MATCH EXPECTED FORMAT", output,
"parseRules printed a malformed-rule warning but should not have:\n"
+ output)

# --- Comments at start of line ---------------------------------------

def test_plain_hash_comment_is_ignored(self):
rules, _, _, out = self._parse(
"# a plain comment\n"
"\\+ ==> -\n"
)
self.assertEqual(len(rules), 1)
self.assertNoMalformedWarning(out)

def test_indented_hash_comment_is_ignored(self):
rules, _, _, out = self._parse(
" # indented comment with 4 spaces\n"
"\t# tab-indented comment\n"
"\\+ ==> -\n"
)
self.assertEqual(len(rules), 1)
self.assertNoMalformedWarning(out)

def test_multiple_comments_do_not_produce_rules(self):
rules, ignoreRules, skipRules, out = self._parse(
"# comment one\n"
"# comment two\n"
"# comment three\n"
)
self.assertEqual(len(rules), 0)
self.assertEqual(len(ignoreRules), 0)
self.assertEqual(len(skipRules), 0)
self.assertNoMalformedWarning(out)

# --- Blank lines ------------------------------------------------------

def test_empty_blank_line_is_ignored(self):
rules, _, _, out = self._parse(
"\\+ ==> -\n"
"\n"
"\\* ==> /\n"
)
self.assertEqual(len(rules), 2)
self.assertNoMalformedWarning(out)

def test_whitespace_only_line_is_ignored(self):
rules, _, _, out = self._parse(
"\\+ ==> -\n"
" \n"
"\t\t\n"
"\\* ==> /\n"
)
self.assertEqual(len(rules), 2)
self.assertNoMalformedWarning(out)

# --- Backward compatibility: LHS starting with '#' --------------------

def test_hash_include_rule_still_parses_as_ignore(self):
"""c_like.rules contains '#include ==> DO_NOT_MUTATE' — must not
be mistaken for a comment."""
_, ignoreRules, _, out = self._parse("#include ==> DO_NOT_MUTATE\n")
self.assertEqual(len(ignoreRules), 1)
self.assertNoMalformedWarning(out)

def test_bare_hash_skip_rule_still_parses_as_skip(self):
"""python.rules contains '# ==> SKIP_MUTATING_REST' — must not
be mistaken for a comment."""
_, _, skipRules, out = self._parse("# ==> SKIP_MUTATING_REST\n")
self.assertEqual(len(skipRules), 1)
self.assertNoMalformedWarning(out)

# --- Mixed content ----------------------------------------------------

def test_header_comment_block_and_rules(self):
"""Realistic case: documentation header plus rules."""
rules, ignoreRules, skipRules, out = self._parse(
"# ============================================\n"
"# Universal rules for arithmetic operator swaps\n"
"# ============================================\n"
"\n"
"# Addition to other operators\n"
"\\+ ==> -\n"
"\\+ ==> *\n"
"\n"
"# Skip rest of line after Python '#' comment\n"
"# ==> SKIP_MUTATING_REST\n"
)
self.assertEqual(len(rules), 2)
self.assertEqual(len(skipRules), 1)
self.assertNoMalformedWarning(out)

# --- Warnings still fire for bad input --------------------------------

def test_malformed_line_still_warns(self):
"""A line with no '==>' and not a comment should still warn."""
_, _, _, out = self._parse("this line is not a rule\n")
self.assertIn("DOES NOT MATCH EXPECTED FORMAT", out)

"""COMBY MODE TESTS"""
class TestCombyIntegration(unittest.TestCase):
"""Integration tests that only run if 'comby' is installed."""

@unittest.skipUnless(HAS_COMBY, "Comby binary not found in PATH")
def test_comby_execution_with_comments(self):
"""Ensure the tool doesn't crash when passing commented rules to Comby."""
# Setup dummy source and rules
fd_src, src_path = tempfile.mkstemp(suffix=".py")
fd_rule, rule_path = tempfile.mkstemp(suffix=".rules")
os.close(fd_src)
os.close(fd_rule)

try:
with open(src_path, "w") as f:
f.write("x = a + b")
with open(rule_path, "w") as f:
f.write("# A comment to ignore\n:[1] + :[2] ==> :[1] - :[2]\n")

# Execute via subprocess to test the full CLI path
cmd = [sys.executable, "-m", "universalmutator.genmutants", src_path, f"--rules={rule_path}", "--comby", "--dump"]
result = subprocess.run(cmd, capture_output=True, text=True)

# 0 means the comments didn't cause a Comby syntax error
self.assertEqual(result.returncode, 0)
# Ensure the valid rule was still applied
self.assertIn("-", result.stdout)
finally:
if os.path.exists(src_path): os.unlink(src_path)
if os.path.exists(rule_path): os.unlink(rule_path)


if __name__ == "__main__":
unittest.main()
36 changes: 21 additions & 15 deletions universalmutator/mutator.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,28 @@ def parseRules(ruleFiles, comby=False):

for (r, ruleSource) in rulesText:
ruleLineNo += 1
if r == "\n":
continue
if " ==> " not in r:
if " ==>" in r:
s = r.split(" ==>")
else:
if r[0] == "#": # Don't warn about comments
continue
print("*" * 60)
print("WARNING:")
print("RULE:", r, "FROM", ruleSource)
print("DOES NOT MATCH EXPECTED FORMAT, AND SO WAS IGNORED")
print("*" * 60)
continue # Allow blank lines and comments, just ignore lines without a transformation
else:

# Rule lines (containing " ==> ") are parsed as rules. This check comes
# first so that existing rules whose LHS legitimately starts with '#'
# (e.g. "#include ==> DO_NOT_MUTATE" in c_like.rules, or the bare
# "# ==> SKIP_MUTATING_REST" in python.rules) continue to work.
if " ==> " in r:
s = r.split(" ==> ")
elif " ==>" in r:
s = r.split(" ==>")
else:
# Not a rule line. Allow full-line comments (starting with '#',
# optionally preceded by whitespace) and blank/whitespace-only
# lines; warn on anything else.
stripped = r.strip()
if stripped == "" or stripped.startswith("#"):
continue
print("*" * 60)
print("WARNING:")
print("RULE:", r, "FROM", ruleSource)
print("DOES NOT MATCH EXPECTED FORMAT, AND SO WAS IGNORED")
print("*" * 60)
continue

if comby:
lhs = s[0]
Expand Down
10 changes: 10 additions & 0 deletions universalmutator/static/universal.rules
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# ============================================================================
# Universal rule set: language-agnostic mutations applied to every source file.
# Rules have the form: <regex> ==> <replacement>
# Two special right-hand sides:
# DO_NOT_MUTATE - do not mutate any line matching <regex>
# SKIP_MUTATING_REST - skip mutating the rest of the line past <regex>
# Lines beginning with '#' (optionally indented) and blank lines are ignored.
# ============================================================================

DO_NOT_MUTATE ==> DO_NOT_MUTATE

# Addition: swap with other arithmetic operators
\+ ==> -
\+ ==> *
\+ ==> /
Expand Down