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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import logging
import re
from functools import lru_cache
from typing import TYPE_CHECKING

from .. import PARSE_INIT_ERRORS
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down
35 changes: 35 additions & 0 deletions desloppify/languages/_framework/treesitter/specs/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="""
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions desloppify/languages/_framework/treesitter/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down
92 changes: 92 additions & 0 deletions desloppify/tests/lang/common/test_kotlin_unused_imports.py
Original file line number Diff line number Diff line change
@@ -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<Int>) {
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<Int>) {
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"]