diff --git a/graphify/extract.py b/graphify/extract.py index b54889c8b..d7f03d47a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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"}), diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index a7bf167ac..f23117b41 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5984,12 +5984,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: @@ -6277,6 +6285,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": @@ -6680,6 +6689,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 @@ -6724,7 +6756,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 ( diff --git a/tests/test_lua_method_calls.py b/tests/test_lua_method_calls.py new file mode 100644 index 000000000..18d28c135 --- /dev/null +++ b/tests/test_lua_method_calls.py @@ -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)