From 1979481b16277fe37888b9ba5d4ee5a7ba9ade82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Lo=CC=81pez?= Date: Sun, 26 Jul 2026 16:22:36 -0400 Subject: [PATCH] fix: don't flag Kotlin delegation/destructuring imports as unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin resolves some imports by convention rather than by name, so the imported symbol never appears in the file body: import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue ... var expanded by remember { mutableStateOf(false) } The identifier search in detect_unused_imports never sees "getValue", so every Compose file using property delegation is reported as having 2 unused imports. Acting on the finding breaks the build. On a Compose Multiplatform project this was 37 of 73 unused-import findings — over half the detector's output. Adds a TreeSitterLangSpec.implicit_import_uses field: (name_pattern, body_pattern) pairs declaring per-language conventions where a matching body pattern means the import is used. Kotlin declares getValue/setValue/provideDelegate (guarded on the `by` keyword) and componentN (guarded on destructuring declarations). Other languages are unaffected — the field defaults to empty. Genuinely dead delegation imports are still flagged: the guard requires the convention's syntax to actually be present in the file. --- .../treesitter/analysis/unused_imports.py | 22 ++- .../_framework/treesitter/specs/compiled.py | 8 ++ .../languages/_framework/treesitter/types.py | 8 ++ .../lang/common/test_kotlin_unused_imports.py | 128 ++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 desloppify/tests/lang/common/test_kotlin_unused_imports.py diff --git a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py index 666f694d1..6853b4c09 100644 --- a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py +++ b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py @@ -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, @@ -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({ @@ -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, diff --git a/desloppify/languages/_framework/treesitter/specs/compiled.py b/desloppify/languages/_framework/treesitter/specs/compiled.py index 128517061..4b13ec1bd 100644 --- a/desloppify/languages/_framework/treesitter/specs/compiled.py +++ b/desloppify/languages/_framework/treesitter/specs/compiled.py @@ -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"(? 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"}