fix(lua): resolve colon-method calls (obj:method())

A colon call `obj:method()` parses as a method_index_expression whose
callee is in the `method` field and whose receiver is in the `table`
field, but _LUA_CONFIG read the absent `name` field, so the callee was
never captured and every colon call was dropped from the call graph.

Point the accessor at the right fields and, when the receiver is `self`,
rewrite the bare method name to its enclosing-table label (Animal:method)
so it resolves like any other colon-method definition. An arbitrary-object
receiver (other:m()) is left unqualified and deferred so it cannot bind to
an unrelated same-named bare function.

Fixes #3991
This commit is contained in:
rajatnagda45 committed 2026-10-02 13:47:17 +05:30
1 parent ef4450d9c2
commit 000b0775c0
3 files changed
+153 -2

No files matched your search

+5 -1
View File
@@ -1529,7 +1529,11 @@ _LUA_CONFIG = LanguageConfig(
call_types=frozenset({"function_call"}),
call_function_field="name",
call_accessor_node_types=frozenset({"method_index_expression"}),
call_accessor_field="name",
# A colon call `obj:method()` parses as a method_index_expression whose callee
# lives in the `method` field and whose receiver lives in the `table` field —
# not the `name`/`object` fields the dot-access languages use (#3991).
call_accessor_field="method",
call_accessor_object_field="table",
name_fallback_child_types=("identifier", "method_index_expression"),
body_fallback_child_types=("block",),
function_boundary_types=frozenset({"function_declaration"}),
+42 -1
View File
@@ -5911,12 +5911,20 @@ def _extract_generic(
# lives in another file (defer to the cross-file resolver). JS/TS named imports
# surface the imported symbol's REAL node into this file's label map.
nid_to_sf: dict[str, str] = {}
# Lua: method nid -> the table it is attached to (`Animal` for `Animal:speak`),
# so a `self:other()` call in its body can be rewritten to the sibling method's
# table-qualified label and resolved (#3991).
lua_self_table: dict[str, str] = {}
for n in nodes:
nid_to_sf[n["id"]] = str(n.get("source_file") or "")
if n.get("type") == "namespace":
continue
raw = n["label"]
normalised = raw.strip("()").lstrip(".")
if config.ts_module == "tree_sitter_lua":
sep = ":" if ":" in normalised else ("." if "." in normalised else None)
if sep:
lua_self_table[n["id"]] = normalised.rsplit(sep, 1)[0]
# For languages with lexical nesting (Python), nested functions should not overwrite
# module-level definitions in the module/file-level label_to_nid map (#3405).
if n["id"] not in scope_parents:
@@ -6204,6 +6212,7 @@ def _extract_generic(
kotlin_qualified_prefix: str | None = None
kotlin_object_receiver: str | None = None
csharp_qualified_prefix: str | None = None
lua_self_qualified: bool = False # Lua self:m() rewritten to Table:m
# Special handling per language
if config.ts_module == "tree_sitter_swift":
@@ -6607,6 +6616,29 @@ def _extract_generic(
# Try reading the node directly (e.g. Java name field is the callee)
callee_name = _read_text(func_node, source)
# Lua: a `self:other()` call in a method body is sugar for a call to a
# sibling method on the same table. The receiver `self` carries no type
# of its own, but the enclosing method's table name is known, so rewrite
# the bare method name to its table-qualified label (`Animal:other`) so
# it resolves like any other colon-method definition. Only the literal
# `self` receiver is rewritten; an arbitrary receiver (`other:m()`) has
# an unknown type and is left unqualified so the fail-closed guard below
# keeps it from binding to an unrelated same-named bare function (#3991).
if (config.ts_module == "tree_sitter_lua"
and is_member_call
and member_receiver == "self"
and callee_name):
_self_table = lua_self_table.get(caller_nid)
if _self_table:
_dot = f"{_self_table}.{callee_name}"
callee_name = f"{_self_table}:{callee_name}"
# A sibling defined with dot syntax (`function Animal.other()`)
# is the same method; prefer it when the colon label is absent.
if callee_name not in label_to_nid and _dot in label_to_nid:
callee_name = _dot
member_receiver = None
lua_self_qualified = True
# _LANGUAGE_BUILTIN_GLOBALS is one union across every language, right for
# a BARE call (String(x) really would become a god node) but wrong for a
# MEMBER call: `open` is a Python builtin and `Set` a JavaScript one, so
@@ -6651,7 +6683,16 @@ def _extract_generic(
_java_defer = (
config.ts_module == "tree_sitter_java" and is_member_call
)
if _python_defer or _java_defer or _builtin_member_call or (
# Lua: a colon call that was NOT rewritten to a table-qualified name
# above (`obj:m()` on an unknown receiver) must not fall back to the
# bare-name map, or `m` would bind to any unrelated top-level
# function named `m`. Defer it to stay fail-closed (#3991).
_lua_member_defer = (
config.ts_module == "tree_sitter_lua"
and is_member_call
and not lua_self_qualified
)
if _python_defer or _java_defer or _builtin_member_call or _lua_member_defer or (
is_member_call
and member_receiver
and (
+106
View File
@@ -0,0 +1,106 @@
from pathlib import Path
from graphify.extract import extract
def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def _call_edges(path: Path, base: Path) -> set[tuple[str, str]]:
result = extract([path], root=base, cache_root=base, parallel=False)
label = {node["id"]: node["label"] for node in result["nodes"]}
return {
(label.get(edge["source"], edge["source"]), label.get(edge["target"], edge["target"]))
for edge in result["edges"]
if edge["relation"] in ("calls", "indirect_call")
}
def test_lua_self_colon_call_resolves_to_sibling_method(tmp_path):
"""`self:other()` inside a colon method is sugar for a call to a sibling
method on the same table. The receiver `self` is untyped on its own, but the
enclosing method's table (`Animal`) is known, so the call must resolve to
`Animal:describe`. Before the fix the colon call was dropped entirely: the
`method_index_expression` callee lives in the `method` field, but the Lua
config read the (absent) `name` field, so no call edge was emitted (#3991)."""
src = _write(
tmp_path / "animal.lua",
"local Animal = {}\n"
"function Animal:speak()\n"
" return self:describe()\n"
"end\n"
"function Animal:describe()\n"
" return self.name\n"
"end\n",
)
assert ("Animal:speak()", "Animal:describe()") in _call_edges(src, tmp_path)
def test_lua_self_colon_call_resolves_dot_defined_sibling(tmp_path):
"""A sibling declared with dot syntax (`function Animal.helper()`) is the
same method; `self:helper()` must still resolve to it (#3991)."""
src = _write(
tmp_path / "animal.lua",
"local Animal = {}\n"
"function Animal.helper()\n"
" return 1\n"
"end\n"
"function Animal:run()\n"
" return self:helper()\n"
"end\n",
)
assert ("Animal:run()", "Animal.helper()") in _call_edges(src, tmp_path)
def test_lua_self_colon_call_does_not_bind_to_unrelated_bare_function(tmp_path):
"""Fail-closed: when the table has no matching sibling, `self:process()` must
NOT bind to an unrelated top-level function named `process`. Resolution stays
scoped to the enclosing table (#3991)."""
src = _write(
tmp_path / "animal.lua",
"function process()\n"
" return 1\n"
"end\n"
"local Animal = {}\n"
"function Animal:run()\n"
" return self:process()\n"
"end\n",
)
assert ("Animal:run()", "process()") not in _call_edges(src, tmp_path)
def test_lua_colon_call_on_unknown_receiver_is_fail_closed(tmp_path):
"""A colon call on an arbitrary receiver (`obj:process()`) has an unknown
type, so it must NOT bind by bare name to an unrelated top-level
`process` — only the literal `self` receiver is resolved (#3991)."""
src = _write(
tmp_path / "animal.lua",
"function process()\n"
" return 1\n"
"end\n"
"local Animal = {}\n"
"function Animal:run()\n"
" local obj = make()\n"
" return obj:process()\n"
"end\n",
)
assert ("Animal:run()", "process()") not in _call_edges(src, tmp_path)
def test_lua_dot_and_plain_calls_still_resolve(tmp_path):
"""Positive control: the dot-call (`M.helper()`) and plain-call (`helper()`)
paths that already worked must keep working (#3991)."""
src = _write(
tmp_path / "mod.lua",
"local M = {}\n"
"function M.helper()\n"
" return 1\n"
"end\n"
"function M.main()\n"
" return M.helper()\n"
"end\n",
)
assert ("M.main()", "M.helper()") in _call_edges(src, tmp_path)