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 @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions desloppify/languages/_framework/treesitter/specs/compiled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions desloppify/languages/_framework/treesitter/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = (
Expand Down
40 changes: 40 additions & 0 deletions desloppify/tests/lang/common/test_treesitter_analysis_direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]