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"}