diff --git a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py index 666f694d1..936c524bc 100644 --- a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py +++ b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py @@ -8,6 +8,7 @@ import logging import re +from functools import lru_cache from typing import TYPE_CHECKING from .. import PARSE_INIT_ERRORS @@ -71,6 +72,7 @@ def detect_unused_imports( return _detect_unused_imports_ecmascript(file_list, spec, parser, language) query = _make_query(language, spec.import_query) + implicit_uses = _compile_implicit_uses(getattr(spec, "implicit_import_uses", ())) entries: list[dict] = [] for filepath in file_list: @@ -94,6 +96,10 @@ def detect_unused_imports( if not raw_path: continue + # Wildcard imports (Kotlin/Java `import a.b.*`) bind no single name. + if _node_text(import_node).rstrip().rstrip(";").endswith("*"): + continue + # Get the import statement's line range so we can exclude it # from the search. import_start = import_node.start_byte @@ -122,12 +128,16 @@ def detect_unused_imports( alias_name = _extract_alias(import_node) # Extract the imported name from the path. - name = alias_name or _extract_import_name(raw_path) + # Backtick-escaped names (Kotlin ``Mockito.`when` ``) are used escaped. + name = (alias_name or _extract_import_name(raw_path)).strip("`") if not name: continue # Check if the name appears in the rest of the file. - if not re.search(r'\b' + re.escape(name) + r'\b', rest): + if re.search(r'\b' + re.escape(name) + r'\b', rest): + continue + implicit = implicit_uses.get(name) + if implicit is None or not implicit.search(rest): entries.append({ "file": filepath, "line": import_node.start_point[0] + 1, @@ -137,6 +147,11 @@ def detect_unused_imports( return entries +@lru_cache(maxsize=32) +def _compile_implicit_uses(pairs: tuple[tuple[str, str], ...]) -> dict[str, re.Pattern[str]]: + return {name: re.compile(pattern) for name, pattern in pairs} + + def _detect_unused_imports_ecmascript( file_list: list[str], spec: TreeSitterLangSpec, @@ -422,7 +437,7 @@ def _extract_alias(import_node) -> str | None: found_as = True continue # The node immediately after "as" is the alias name. - if found_as and child.type in ("name", "identifier", "namespace_name"): + if found_as and child.type in ("name", "identifier", "namespace_name", "type_identifier"): return _node_text(child) return None @@ -440,7 +455,7 @@ def _iter_children(node): yield child elif child.type in ( "namespace_use_clause", "import_clause", - "namespace_alias", "as_pattern", + "namespace_alias", "as_pattern", "import_alias", ): yield from _iter_children(child) diff --git a/desloppify/languages/_framework/treesitter/specs/compiled.py b/desloppify/languages/_framework/treesitter/specs/compiled.py index 128517061..c8a02df01 100644 --- a/desloppify/languages/_framework/treesitter/specs/compiled.py +++ b/desloppify/languages/_framework/treesitter/specs/compiled.py @@ -96,6 +96,40 @@ ), ) +# Kotlin operator conventions: imported operator extensions are called by +# syntax, not by name. +_KOTLIN_OPERATOR_USES: tuple[tuple[str, str], ...] = ( + ("getValue", r"\bby\b"), + ("setValue", r"\bvar\b[^\n]*\bby\b"), + ("provideDelegate", r"\bby\b"), + ("contains", r"\bin\b"), + ("get", r"\["), + ("set", r"\]\s*[-+*/%]?="), + ("invoke", r"[\w)\]]\s*[({]"), + ("iterator", r"\bfor\s*\("), + ("hasNext", r"\bfor\s*\("), + ("next", r"\bfor\s*\("), + ("compareTo", r"[<>]"), + ("rangeTo", r"\.\."), + ("rangeUntil", r"\.\.<"), + ("plus", r"\+"), + ("minus", r"-"), + ("times", r"\*"), + ("div", r"/"), + ("rem", r"%"), + ("unaryPlus", r"\+"), + ("unaryMinus", r"-"), + ("not", r"!"), + ("inc", r"\+\+"), + ("dec", r"--"), + ("plusAssign", r"\+="), + ("minusAssign", r"-="), + ("timesAssign", r"\*="), + ("divAssign", r"/="), + ("remAssign", r"%="), + *((f"component{n}", r"\(\s*\w+\s*(?::[^,()]*)?,") for n in range(1, 10)), +) + KOTLIN_SPEC = TreeSitterLangSpec( grammar="kotlin", function_query=""" @@ -109,6 +143,7 @@ (identifier) @path) @import """, resolve_import=resolve_kotlin_import, + implicit_import_uses=_KOTLIN_OPERATOR_USES, class_query=""" (class_declaration (type_identifier) @name diff --git a/desloppify/languages/_framework/treesitter/types.py b/desloppify/languages/_framework/treesitter/types.py index 152d2abdf..541cdeff7 100644 --- a/desloppify/languages/_framework/treesitter/types.py +++ b/desloppify/languages/_framework/treesitter/types.py @@ -17,6 +17,10 @@ class TreeSitterLangSpec: import_query: str = "" resolve_import: Callable[[str, str, str], str | None] | None = None + # (imported name, regex) pairs: an import whose name never appears in the + # file still counts as used when the regex matches, e.g. Kotlin operator + # conventions (`getValue` is called by `by`, `contains` by `in`). + implicit_import_uses: tuple[tuple[str, str], ...] = () class_query: str = "" diff --git a/desloppify/tests/lang/common/test_kotlin_unused_imports.py b/desloppify/tests/lang/common/test_kotlin_unused_imports.py new file mode 100644 index 000000000..4bcf1e0ac --- /dev/null +++ b/desloppify/tests/lang/common/test_kotlin_unused_imports.py @@ -0,0 +1,92 @@ +"""Kotlin-specific behaviour of the tree-sitter unused import detector.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_language_pack") + +from desloppify.languages._framework.treesitter.analysis.unused_imports import ( # noqa: E402 + detect_unused_imports, +) +from desloppify.languages._framework.treesitter.specs.compiled import KOTLIN_SPEC # noqa: E402 + + +def _unused(tmp_path: Path, source: str) -> list[str]: + path = tmp_path / "Sample.kt" + path.write_text(source, encoding="utf-8") + return sorted(entry["name"] for entry in detect_unused_imports([str(path)], KOTLIN_SPEC)) + + +def test_operator_convention_imports_count_as_used(tmp_path: Path) -> None: + source = """package p + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import com.acme.contains +import com.acme.component1 +import com.acme.component2 +import com.acme.plus + +fun f(items: Set) { + var count by mutableStateOf(0) + val (a, b) = Pair(1, 2) + if (count in items) count = a + b +} +""" + assert _unused(tmp_path, source) == [] + + +def test_operator_imports_without_matching_syntax_are_unused(tmp_path: Path) -> None: + source = """package p + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + +fun f() { + val count = 1 +} +""" + assert _unused(tmp_path, source) == ["getValue", "setValue"] + + +def test_set_value_needs_a_var_delegate(tmp_path: Path) -> None: + source = """package p + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + +fun f(state: State) { + val count by state +} +""" + assert _unused(tmp_path, source) == ["setValue"] + + +def test_backticked_alias_and_wildcard_imports(tmp_path: Path) -> None: + source = """package p + +import org.mockito.Mockito.`when` +import com.acme.LongName as Short +import com.acme.Unused as AlsoUnused +import com.acme.model.* + +fun f() { + `when`(Short.make()).thenReturn(null) +} +""" + assert _unused(tmp_path, source) == ["AlsoUnused"] + + +def test_plain_unused_import_is_still_reported(tmp_path: Path) -> None: + source = """package p + +import com.acme.BuriedGem +import com.acme.Used + +fun f(u: Used) = u +""" + assert _unused(tmp_path, source) == ["BuriedGem"]