From 138132ff8dbceaa54ed0828d98278bdfe3bf2387 Mon Sep 17 00:00:00 2001 From: ariancovac <89427027+ariancovac@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:37 -0300 Subject: [PATCH] fix: skip Kotlin operator convention imports in unused-import detection Kotlin invokes convention (operator) functions implicitly: importing androidx.compose.runtime.getValue is required for property delegation (val x by someState) even though the name never appears in the file text. Textual unused-import detection flagged such live imports as unused; removing them breaks compilation. Add an implicit_import_names set to the tree-sitter language spec and populate it for Kotlin with the convention-function table (delegation, unary, arithmetic, augmented assignment, containment/indexing, invocation, comparison, iteration, infix bitwise). Imports resolving to those names are skipped by unused-import detection. --- .../treesitter/analysis/unused_imports.py | 7 ++++ .../_framework/treesitter/specs/compiled.py | 21 ++++++++++ .../languages/_framework/treesitter/types.py | 6 +++ .../common/test_treesitter_analysis_direct.py | 40 +++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py index 666f694d1..c15bbd3d0 100644 --- a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py +++ b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py @@ -126,6 +126,13 @@ def detect_unused_imports( if not name: continue + # Some languages invoke imported functions implicitly by + # convention (e.g. Kotlin property delegation via getValue / + # setValue), so the name never appears in the file text even + # though the import is live. + if name in getattr(spec, "implicit_import_names", frozenset()): + continue + # Check if the name appears in the rest of the file. if not re.search(r'\b' + re.escape(name) + r'\b', rest): entries.append({ diff --git a/desloppify/languages/_framework/treesitter/specs/compiled.py b/desloppify/languages/_framework/treesitter/specs/compiled.py index 128517061..cf85df2d4 100644 --- a/desloppify/languages/_framework/treesitter/specs/compiled.py +++ b/desloppify/languages/_framework/treesitter/specs/compiled.py @@ -109,6 +109,27 @@ (identifier) @path) @import """, resolve_import=resolve_kotlin_import, + # Kotlin convention (operator) functions are invoked implicitly by the + # compiler, so an import of one can be live even when its name never + # appears in the file text — e.g. `import androidx.compose.runtime.getValue` + # is required for `val x by someState` property delegation. Names listed + # here are skipped by unused-import detection. + implicit_import_names=frozenset({ + # unary + "unaryPlus", "unaryMinus", "not", "inc", "dec", + # arithmetic / ranges + "plus", "minus", "times", "div", "rem", "mod", "rangeTo", "rangeUntil", + # augmented assignments + "plusAssign", "minusAssign", "timesAssign", "divAssign", "remAssign", + # containment, indexing, invocation, comparison + "contains", "get", "set", "invoke", "compareTo", "equals", + # iteration (for loops) + "iterator", "next", "hasNext", + # property delegation + "provideDelegate", "getValue", "setValue", + # infix bitwise conventions + "and", "or", "xor", "shl", "shr", "ushr", + }), 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..76a3daf04 100644 --- a/desloppify/languages/_framework/treesitter/types.py +++ b/desloppify/languages/_framework/treesitter/types.py @@ -18,6 +18,12 @@ class TreeSitterLangSpec: import_query: str = "" resolve_import: Callable[[str, str, str], str | None] | None = None + # Imported names whose usage a language invokes implicitly by convention + # (e.g. Kotlin operator/property-delegation functions). A textual + # reference search cannot rule these out, so unused-import detection + # must skip them to avoid false positives. + implicit_import_names: frozenset[str] = frozenset() + class_query: str = "" log_patterns: tuple[str, ...] = ( diff --git a/desloppify/tests/lang/common/test_treesitter_analysis_direct.py b/desloppify/tests/lang/common/test_treesitter_analysis_direct.py index c06e2e2a2..6201484e6 100644 --- a/desloppify/tests/lang/common/test_treesitter_analysis_direct.py +++ b/desloppify/tests/lang/common/test_treesitter_analysis_direct.py @@ -219,3 +219,43 @@ def test_unused_import_helpers_and_detection(monkeypatch) -> None: spec = SimpleNamespace(grammar="py", import_query="query") entries = unused_imports_mod.detect_unused_imports(["src/app.py"], spec) assert entries == [{"file": "src/app.py", "line": 1, "name": "module"}] + + +def test_unused_imports_skip_implicit_convention_names(monkeypatch) -> None: + import_node = FakeNode( + "import_statement", + children=[FakeNode("identifier", text="import"), FakeNode("identifier", text="androidx.compose.runtime.getValue")], + start_point=(0, 0), + end_point=(0, 46), + start_byte=0, + end_byte=46, + ) + path_node = FakeNode("identifier", text="androidx.compose.runtime.getValue") + monkeypatch.setattr(unused_imports_mod, "_get_parser", lambda _grammar: ("parser", "lang")) + monkeypatch.setattr(unused_imports_mod, "_make_query", lambda *_a, **_k: "query") + monkeypatch.setattr( + unused_imports_mod, + "get_or_parse_tree", + lambda *_a, **_k: ( + b"import androidx.compose.runtime.getValue\nval x by state\n", + SimpleNamespace(root_node=FakeNode("root")), + ), + ) + monkeypatch.setattr( + unused_imports_mod, + "_run_query", + lambda *_a, **_k: [(0, {"import": import_node, "path": path_node})], + ) + monkeypatch.setattr(unused_imports_mod, "_unwrap_node", lambda node: node) + monkeypatch.setattr(unused_imports_mod, "_node_text", lambda node: node.text.decode("utf-8")) + + spec = SimpleNamespace( + grammar="kotlin", + import_query="query", + implicit_import_names=frozenset({"getValue"}), + ) + assert unused_imports_mod.detect_unused_imports(["src/App.kt"], spec) == [] + + plain_spec = SimpleNamespace(grammar="kotlin", import_query="query") + entries = unused_imports_mod.detect_unused_imports(["src/App.kt"], plain_spec) + assert entries == [{"file": "src/App.kt", "line": 1, "name": "getValue"}]