From f3d49a28ecfda8444ed5ff0527604b22d1b01300 Mon Sep 17 00:00:00 2001 From: Shivam Tiwari <33183708+shivamtiwari3@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:45:30 +0400 Subject: [PATCH] fix(lua): emit an imports edge for bare require() statements (#3320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_import_lua` was only reachable from `variable_declaration`, so `local x = require("m")` produced an `imports` edge and a bare `require("m")` statement produced nothing. tree-sitter-lua parses the bare form as a plain `function_call`, and the whole Neovim config idiom require("config.lazy") require("config.options") is written that way — a 14-file LazyVim config came out with 13 nodes and 2 edges, none of them imports. `function_call` cannot simply be added to `import_types`: that branch returns without walking children, which would have dropped every Lua `calls` edge. Add `import_call_types` instead — dispatched to the same handler, but falling through so class/function/call dispatch still sees the node. The module-level walk and the call walk both consult it, so a lazy require inside a function body is captured too, matching what `_require_imports_js` already does for CommonJS. `_import_lua` reads the module from the call's own callee and arguments rather than regex-scanning the node text, so a nested `require("a").setup(require("b"))` reports each module once instead of attributing the inner one twice. --- CHANGELOG.md | 1 + graphify/extract.py | 74 +++++++++++++------ graphify/extractors/engine.py | 14 ++++ graphify/extractors/models.py | 6 ++ tests/test_lua_bare_require.py | 125 +++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 tests/test_lua_bare_require.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f797354ecb..9c6fdc5702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: when duplicate nodes merge, the richer (more complete) node is now kept as the survivor and the losers' non-empty fields are folded in, instead of a shorter-id passing mention winning and dropping content (#3372, thanks @abhay-codes07). - Fix: a C# generic call site with explicit type arguments — `Get(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get` as the callee and failing to match (#3406, thanks @abhay-codes07). - Fix: `this.X = function` / `this.X = () => …` members are now captured in every enclosing-function form (function expressions, arrows, IIFEs, callbacks), not just function declarations (#3408, thanks @abhay-codes07). +- Fix: a bare Lua `require("m")` statement — the idiom every Neovim `init.lua` is written in — now produces an `imports` edge; only the assigned form `local x = require("m")` was recognized, so a config built from bare requires had no import edges at all (#3320, thanks @artbylmz). ## 0.9.56 (2026-09-07) diff --git a/graphify/extract.py b/graphify/extract.py index 32c59484db..fe7192efe8 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1046,27 +1046,57 @@ def _ruby_sanitize_method_name(name: str) -> str: ) +def _lua_bare_require_module(node, source: bytes) -> str: + """Return the module name of a bare Lua ``require`` call, or ``""``. + + A `require("m")` statement with no assignment is a plain `function_call`, not + a `variable_declaration` — the idiom every Neovim `init.lua` is written in + (#3320). Matching on the call's own callee (rather than regex-scanning the + node text) keeps a nested form like `require("a").setup(require("b"))` from + reporting the inner module twice: the outer call's callee is an index + expression, so only the two genuine `require` calls match, each once. + """ + callee = node.child_by_field_name("name") + if callee is None or callee.type != "identifier": + return "" + if _read_text(callee, source) != "require": + return "" + args = node.child_by_field_name("arguments") + if args is None: + return "" + # `require("m")`, `require "m"` and `require [[m]]` all reach the module name + # through a string node; read its content child so quotes stay out of the id. + for child in args.children if args.type == "arguments" else (): + if child.type == "string": + for part in child.children: + if part.type == "string_content": + return _read_text(part, source).strip() + return _read_text(child, source).strip("\"'[] ") + return "" + + def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: - """Extract require('module') from Lua variable_declaration nodes.""" - text = _read_text(node, source) - import re - m = re.search(r"""require\s*[\('"]\s*['"]?([^'")\s]+)""", text) - if m: - raw_module = m.group(1) - if raw_module: - tgt_nid = _resolve_lua_import_target(raw_module, str_path) - if tgt_nid: - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "confidence_score": 1.0, - "source_file": str_path, - "source_location": str(node.start_point[0] + 1), - "weight": 1.0, - }) + """Extract require('module') from Lua variable_declaration / bare-call nodes.""" + if node.type == "function_call": + raw_module = _lua_bare_require_module(node, source) + else: + import re + m = re.search(r"""require\s*[\('"]\s*['"]?([^'")\s]+)""", _read_text(node, source)) + raw_module = m.group(1) if m else "" + if raw_module: + tgt_nid = _resolve_lua_import_target(raw_module, str_path) + if tgt_nid: + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": str(node.start_point[0] + 1), + "weight": 1.0, + }) _LUA_CONFIG = LanguageConfig( @@ -1075,6 +1105,10 @@ def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_ class_types=frozenset(), function_types=frozenset({"function_declaration"}), import_types=frozenset({"variable_declaration"}), + # A bare `require("m")` statement carries an import but is also an ordinary + # call node, so it goes through import_call_types (no early return) rather + # than import_types (#3320). + import_call_types=frozenset({"function_call"}), call_types=frozenset({"function_call"}), call_function_field="name", call_accessor_node_types=frozenset({"method_index_expression"}), diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 6b0dbf87df..446bb77b38 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3274,6 +3274,14 @@ def ensure_named_node(name: str, line: int) -> str: def walk(node, parent_class_nid: str | None = None) -> None: t = node.type + # Import-carrying call statements (Lua `require("m")` with no assignment). + # Unlike import_types this does not return: the node is an ordinary call + # expression, so class/function/call dispatch below still has to see it + # and its children (#3320). The handler decides whether the call is + # actually an import. + if t in config.import_call_types and config.import_handler: + config.import_handler(node, source, file_nid, stem, edges, str_path, scope_stack) + # Import types if t in config.import_types: if config.import_handler: @@ -5302,6 +5310,12 @@ def walk_calls( and node.type in ("lexical_declaration", "variable_declaration")): _require_imports_js(node, source, caller_nid, stem, edges, str_path) + # Lua's twin of the block above: a bare `require("m")` is valid at any + # depth, and lazy requires inside a function body are as common in Neovim + # config as the top-level form the module-level walk covers (#3320). + if node.type in config.import_call_types and config.import_handler: + config.import_handler(node, source, file_nid, stem, edges, str_path, scope_stack) + if node.type in config.call_types: # JS/TS dynamic imports: await import('./foo.js') if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): diff --git a/graphify/extractors/models.py b/graphify/extractors/models.py index 014907c0d5..fe6eb48061 100644 --- a/graphify/extractors/models.py +++ b/graphify/extractors/models.py @@ -18,6 +18,12 @@ class LanguageConfig: class_types: frozenset = frozenset() function_types: frozenset = frozenset() import_types: frozenset = frozenset() + # Node types that may *also* carry an import (Lua's bare `require("m")` + # statement is a plain `function_call`), dispatched to import_handler but + # without the `return` that import_types performs — the node is still a call + # expression that may contain declarations and nested calls, so the walk has + # to continue into it (#3320). + import_call_types: frozenset = frozenset() call_types: frozenset = frozenset() static_prop_types: frozenset = frozenset() helper_fn_names: frozenset = frozenset() diff --git a/tests/test_lua_bare_require.py b/tests/test_lua_bare_require.py new file mode 100644 index 0000000000..7cb8aaee98 --- /dev/null +++ b/tests/test_lua_bare_require.py @@ -0,0 +1,125 @@ +"""Regression tests for bare Lua `require("m")` statements (#3320). + +`_import_lua` only ran for `variable_declaration` nodes, so an import edge came +out of `local x = require("m")` but not out of the far more common Neovim idiom +— a `require("m")` statement with no assignment, which tree-sitter-lua parses as +a plain `function_call`. Every `init.lua` written as a list of bare requires +therefore produced zero import edges. The fix routes those call nodes through +the same handler via `import_call_types`, which (unlike `import_types`) does not +stop the walk, so Lua call extraction is unaffected. +""" +from __future__ import annotations + +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 _import_targets(result: dict, source: str) -> list[str]: + return [ + edge["target"] + for edge in result["edges"] + if edge["relation"] == "imports" and edge["source"] == source + ] + + +def test_bare_require_emits_import_edge(tmp_path: Path): + entry = _write(tmp_path / "init.lua", 'require("lazy")\n') + module = _write(tmp_path / "lazy.lua", "local M = {}\nreturn M\n") + + result = extract([entry, module], cache_root=tmp_path) + + assert _import_targets(result, "init") == ["lazy"] + + +def test_bare_require_matches_the_assigned_form(tmp_path: Path): + """The two spellings of the same dependency must produce the same edge.""" + module = _write(tmp_path / "lazy.lua", "local M = {}\nreturn M\n") + bare = _write(tmp_path / "bare.lua", 'require("lazy")\n') + assigned = _write(tmp_path / "assigned.lua", 'local ok = require("lazy")\n') + + result = extract([module, bare, assigned], cache_root=tmp_path) + + assert _import_targets(result, "bare") == _import_targets(result, "assigned") + + +def test_bare_require_without_parentheses(tmp_path: Path): + entry = _write(tmp_path / "init.lua", 'require "lazy"\n') + module = _write(tmp_path / "lazy.lua", "local M = {}\nreturn M\n") + + result = extract([entry, module], cache_root=tmp_path) + + assert _import_targets(result, "init") == ["lazy"] + + +def test_every_bare_require_in_a_list_is_reported(tmp_path: Path): + entry = _write( + tmp_path / "init.lua", + 'require("options")\nrequire("keymaps")\nrequire("plugins")\n', + ) + modules = [ + _write(tmp_path / f"{name}.lua", "-- module\n") + for name in ("options", "keymaps", "plugins") + ] + + result = extract([entry, *modules], cache_root=tmp_path) + + assert _import_targets(result, "init") == ["options", "keymaps", "plugins"] + + +def test_lazy_require_inside_a_function_body(tmp_path: Path): + """A require at any lexical depth is still a dependency of the file.""" + entry = _write( + tmp_path / "init.lua", + "function setup()\n require(\"lazy\")\nend\n", + ) + module = _write(tmp_path / "lazy.lua", "-- module\n") + + result = extract([entry, module], cache_root=tmp_path) + + assert _import_targets(result, "init") == ["lazy"] + + +def test_a_non_require_call_produces_no_import_edge(tmp_path: Path): + entry = _write(tmp_path / "init.lua", "local M = {}\nfunction M.go() end\nM.go()\n") + + result = extract([entry], cache_root=tmp_path) + + assert _import_targets(result, "init") == [] + + +def test_bare_require_does_not_suppress_call_extraction(tmp_path: Path): + """`import_call_types` must not swallow the call node the way imports do.""" + entry = _write( + tmp_path / "init.lua", + 'require("lazy")\n' + "function helper() end\n" + "function setup()\n helper()\nend\n", + ) + module = _write(tmp_path / "lazy.lua", "-- module\n") + + result = extract([entry, module], cache_root=tmp_path) + + calls = { + (edge["source"], edge["target"]) + for edge in result["edges"] + if edge["relation"] == "calls" + } + assert ("init_setup", "init_helper") in calls + assert _import_targets(result, "init") == ["lazy"] + + +def test_bare_require_is_reported_once(tmp_path: Path): + """The module-level walk and the call walk must not both emit the edge.""" + entry = _write(tmp_path / "init.lua", 'require("lazy")\n') + module = _write(tmp_path / "lazy.lua", "-- module\n") + + result = extract([entry, module], cache_root=tmp_path) + + assert len(_import_targets(result, "init")) == 1