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 @@ -48,6 +48,22 @@
})


def _is_implicitly_used(name: str, body: str, spec: TreeSitterLangSpec) -> bool:
"""Return True if ``name`` is resolved by language convention rather than by name.

Some languages resolve imports through syntax that never spells the imported
symbol out — Kotlin's property delegation (``var x by remember { ... }``) requires
``getValue``/``setValue`` imports, and destructuring requires ``componentN``.
A plain identifier search reports those as unused, and removing them breaks the
build. ``spec.implicit_import_uses`` declares those conventions per language.
"""
# getattr keeps duck-typed spec stubs (used in tests) working.
for name_pattern, body_pattern in getattr(spec, "implicit_import_uses", ()):
if re.search(name_pattern, name) and re.search(body_pattern, body):
return True
return False


def detect_unused_imports(
file_list: list[str],
spec: TreeSitterLangSpec,
Expand Down Expand Up @@ -108,6 +124,7 @@ def detect_unused_imports(
unused_names = [
n for n in grouped_names
if not re.search(r'\b' + re.escape(n) + r'\b', rest)
and not _is_implicitly_used(n, rest, spec)
]
if unused_names:
entries.append({
Expand All @@ -127,7 +144,10 @@ def detect_unused_imports(
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 (
not re.search(r'\b' + re.escape(name) + r'\b', rest)
and not _is_implicitly_used(name, rest, spec)
):
entries.append({
"file": filepath,
"line": import_node.start_point[0] + 1,
Expand Down
8 changes: 8 additions & 0 deletions desloppify/languages/_framework/treesitter/specs/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@
(type_identifier) @name
(class_body) @body) @class
""",
implicit_import_uses=(
# Property delegation (`var x by remember { mutableStateOf(0) }`) resolves the
# getValue/setValue/provideDelegate operators through imported extensions that
# are never spelled out in the body. Pervasive in Compose Multiplatform.
(r"^(?:getValue|setValue|provideDelegate)$", r"(?<![\w.])by\s"),
# Destructuring declarations (`val (a, b) = pair`) call componentN() implicitly.
(r"^component\d+$", r"(?m)^\s*(?:val|var)\s*\("),
),
log_patterns=(
r"^\s*(?:println\(|print\(|Logger\.|log\.)",
),
Expand Down
8 changes: 8 additions & 0 deletions desloppify/languages/_framework/treesitter/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ class TreeSitterLangSpec:

class_query: str = ""

# Imports that a language resolves by *convention* rather than by name, so the
# imported symbol never appears in the file body. Each entry is a
# ``(name_pattern, body_pattern)`` pair: when an import's simple name matches
# ``name_pattern`` and ``body_pattern`` is found in the file body, the import is
# treated as used. Example: Kotlin's ``import ...getValue`` is required by
# ``var x by remember { ... }`` but "getValue" is never written out.
implicit_import_uses: tuple[tuple[str, str], ...] = ()

log_patterns: tuple[str, ...] = (
r"^\s*(?:fmt\.Print|log\.)",
r"^\s*(?:println!|eprintln!|dbg!)",
Expand Down
128 changes: 128 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,128 @@
"""Regression tests for Kotlin unused import detection.

Kotlin resolves some imports by convention rather than by name, so the imported
symbol never appears in the file body. Flagging those as unused is actively
harmful: removing them breaks compilation.
"""

from __future__ import annotations

import textwrap


def _detect(tmp_path, contents: str, name: str = "Screen.kt"):
from desloppify.languages._framework.treesitter.analysis.unused_imports import (
detect_unused_imports,
)
from desloppify.languages._framework.treesitter.specs.compiled import KOTLIN_SPEC

source = tmp_path / name
source.write_text(textwrap.dedent(contents).lstrip())
return detect_unused_imports([str(source)], KOTLIN_SPEC)


def _names(findings) -> set[str]:
return {f["name"] for f in findings}


def test_delegation_imports_are_not_flagged_when_by_is_used(tmp_path):
"""`var x by remember { ... }` needs getValue/setValue even though it never names them."""
findings = _detect(
tmp_path,
"""
package com.example

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun Screen() {
var expanded by remember { mutableStateOf(false) }
if (expanded) {
expanded = false
}
}
""",
)

assert _names(findings) == set()


def test_delegation_imports_are_flagged_without_by(tmp_path):
"""No delegation in the file means the operator imports really are dead."""
findings = _detect(
tmp_path,
"""
package com.example

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

@Composable
fun Screen() {
Text("static")
}
""",
)

assert _names(findings) == {"getValue", "setValue"}


def test_identifier_ending_in_by_does_not_mask_dead_delegation_imports(tmp_path):
"""`nearby` / `standby.set(...)` must not be mistaken for the `by` keyword."""
findings = _detect(
tmp_path,
"""
package com.example

import androidx.compose.runtime.getValue

fun render(nearby: String) {
println(nearby)
}
""",
)

assert _names(findings) == {"getValue"}


def test_component_imports_are_not_flagged_with_destructuring(tmp_path):
findings = _detect(
tmp_path,
"""
package com.example

import com.example.geo.component1
import com.example.geo.component2

fun render(point: GeoPoint) {
val (lat, lon) = point
println("$lat $lon")
}
""",
)

assert _names(findings) == set()


def test_genuinely_unused_import_is_still_flagged(tmp_path):
findings = _detect(
tmp_path,
"""
package com.example

import androidx.compose.foundation.background
import androidx.compose.runtime.Composable

@Composable
fun Screen() {
Text("hello")
}
""",
)

assert _names(findings) == {"background"}