diff --git a/README.md b/README.md index fad81a7..1152ce6 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Tokens inside strings, comments, and quoted Clojure forms are left alone. A repl | Clojure | clj-mutate's head symbols: `+`/`-`, `*` to `/`, `inc`/`dec`, comparisons, `=`/`not=`, `if`/`if-not`, `when`/`when-not`, `and`/`or`, and the seq, predicate, and coercion pairs. `true`/`false` and `0`/`1` anywhere. | | Java | `true`/`false`, `==`/`!=`, `<`/`<=`, `>`/`>=`, `+`/`-`, `*`/`/`, `&&`/`||`, delete `!` and unary `-`, `0`/`1`. Constructors are not entries, matching crapper. | | Go | mutate4go: `+`/`-`, `*` to `/` only, comparisons, `==`/`!=`, `true`/`false`, `&&`/`||`, `0`/`1`. | -| TypeScript | the Java set, plus `===`/`!==`. | +| TypeScript | the Java set, plus `===`/`!==`, `??` to `||`, and `?.` to `.`. A call or index drops `?.` (`a?.()` becomes `a()`). `.js`, `.jsx`, `.mjs`, and `.cjs` use these rules. | | Rust | the Java set (`&&`/`||`, `!`, unary `-`). | | Python | the Java set, spelled `and`/`or`, `not`, and `True`/`False`. | diff --git a/src/mutator/functions.py b/src/mutator/functions.py index 3f6ef8a..25d11ae 100644 --- a/src/mutator/functions.py +++ b/src/mutator/functions.py @@ -115,11 +115,29 @@ def project_functions(source: str, path: Path, root: Path): ) -def _owner(functions, line: int): - matches = [fn for fn in functions if fn.start_line <= line <= fn.end_line] +def _contains(fn, line: int, byte: int | None) -> bool: + if fn.start_line > line or fn.end_line < line: + return False + start = getattr(fn, "start_byte", -1) + end = getattr(fn, "end_byte", -1) + if byte is None or start < 0 or end < 0: + return True + return start <= byte < end + + +def _tightness(fn) -> int: + start = getattr(fn, "start_byte", -1) + end = getattr(fn, "end_byte", -1) + if start >= 0 and end >= start: + return end - start + return (fn.end_line - fn.start_line) * 1_000_000 + + +def _owner(functions, line: int, byte: int | None = None): + matches = [fn for fn in functions if _contains(fn, line, byte)] if not matches: return None - return min(matches, key=lambda fn: (fn.end_line - fn.start_line, fn.start_line)) + return min(matches, key=lambda fn: (_tightness(fn), fn.start_line)) def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Site]: @@ -133,7 +151,7 @@ def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Si } found: list[Site] = [] for raw in discover_raw(source, language, path.as_posix()): - owner = _owner(functions, raw.line) + owner = _owner(functions, raw.line, raw.start) if owner is None: continue private = privacy[id(owner)] diff --git a/src/mutator/sites.py b/src/mutator/sites.py index 43ea7c5..4bfee1e 100644 --- a/src/mutator/sites.py +++ b/src/mutator/sites.py @@ -2,7 +2,9 @@ Clojure follows clj-mutate's symbol rules. Go follows mutate4go, including one-way `*` to `/`. Java follows mutate4java. TypeScript, Rust, and Python -use the same decisions as Java, spelled in that language. +use the same decisions as Java, spelled in that language. TypeScript also +mutates `??` to `||` and `?.` to `.` (removed on a call or index). JavaScript +files use the TypeScript rules. """ from __future__ import annotations @@ -24,6 +26,7 @@ _BINARY = { "!==": "===", "&&": "||", "||": "&&", + "??": "||", "and": "or", "or": "and", } @@ -100,8 +103,12 @@ class RawSite: def _grammar(language: str, path: str) -> str: - if language == "typescript" and path.endswith(".tsx"): - return "tsx" + if language == "typescript": + if path.endswith(".tsx"): + return "tsx" + if path.endswith((".js", ".jsx", ".mjs", ".cjs")): + return "javascript" + return "typescript" if language == "go": return "go" return language @@ -118,7 +125,7 @@ def _category(token: str) -> str: return "comparison" if token in {"==", "!=", "===", "!=="}: return "equality" - if token in {"&&", "||", "and", "or"}: + if token in {"&&", "||", "??", "and", "or"}: return "logical" return "arithmetic" @@ -126,9 +133,27 @@ def _category(token: str) -> str: def _binary_mutant(language: str, token: str) -> str | None: if language == "go" and token == "/": return None + if token == "??" and language != "typescript": + return None return _BINARY.get(token) +def _optional_mutant(node) -> str | None: + """`a?.b` becomes `a.b`. A call or index drops `?.`, so `a?.()` becomes `a()`.""" + + parent = node.parent + if parent is None: + return None + host = parent.parent if parent.type == "optional_chain" else parent + if host is None: + return None + if host.type == "member_expression": + return "." + if host.type in {"call_expression", "subscript_expression"}: + return "" + return None + + def _add(found: list[RawSite], node, original: str, mutant: str, category: str) -> None: if mutant == original: return @@ -217,6 +242,10 @@ def _visit(node, data: bytes, language: str, found: list[RawSite]) -> None: mutant = _binary_mutant(language, token) if mutant is not None: _add(found, node, token, mutant, _category(token)) + if language == "typescript" and token == "?.": + optional = _optional_mutant(node) + if optional is not None: + _add(found, node, "?.", optional, "optional") if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}: _add(found, node, token, "", "unary") if token == "-" and parent in _UNARY_PARENTS: diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py index dad3050..0daa4cd 100644 --- a/tests/test_boundaries.py +++ b/tests/test_boundaries.py @@ -219,6 +219,10 @@ def test_privacy_headers_digests_owners_and_mutation_fields(tmp_path): def test_grammar_guards_and_non_head_clojure_symbols(tmp_path): assert _grammar("typescript", "src/app.tsx") == "tsx" assert _grammar("typescript", "src/app.ts") == "typescript" + assert _grammar("typescript", "src/app.js") == "javascript" + assert _grammar("typescript", "src/app.mjs") == "javascript" + assert _grammar("typescript", "src/app.cjs") == "javascript" + assert _grammar("typescript", "src/app.jsx") == "javascript" assert _grammar("python", "src/app.py") == "python" sites, pairs = _pairs(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn place []\n [inc])\n") diff --git a/tests/test_sites.py b/tests/test_sites.py index 1a36f1a..d4d3192 100644 --- a/tests/test_sites.py +++ b/tests/test_sites.py @@ -163,3 +163,34 @@ def test_python_private_and_class_namespace(tmp_path): def test_string_operators_are_not_sites(tmp_path): source = 'def place():\n return "x > 0 and true"\n' assert _sites(tmp_path, "src/demo/app.py", source) == [] + + +def test_typescript_mutates_nullish_and_optional_chaining(tmp_path): + source = """ +export function place(a, b) { + return a ?? b?.c ?? b?.[0] ?? b?.(); +} +""" + sites = _sites(tmp_path, "src/demo/app.ts", source) + assert sum(site.original == "??" and site.mutant == "||" for site in sites) == 3 + assert {site.mutant for site in sites if site.original == "?."} == {".", ""} + assert {site.namespace for site in sites} == {"demo.app"} + + +def test_javascript_express_handler_owns_its_nullish_site(tmp_path): + source = """ +export function mount(app) { + app.get("/users", (req, res) => { + return req.body ?? false; + }); +} +""" + sites = _sites(tmp_path, "src/demo/routes.js", source) + assert {site.namespace for site in sites} == {"demo.routes"} + assert ("GET /users", "??", "||") in {(site.name, site.original, site.mutant) for site in sites} + assert all(site.name != "mount" for site in sites) + + +def test_a_quoted_nullish_operator_is_not_a_site(tmp_path): + source = 'export function place() {\n return "a ?? b?.c";\n}\n' + assert _sites(tmp_path, "src/demo/app.ts", source) == []