mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 16:09:26 +00:00
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:
1 parent
ef4450d9c2
commit
000b0775c0
3 files changed
+153
-2
No files matched your search
+5
-1
@@ -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"}),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user