mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
A receiver-less Ruby method call written without parentheses — the most
common call form in idiomatic Ruby — was dropped from the call graph:
def run
build # a method call on self
end
Tree-sitter parses that `build` as a bare `identifier`, structurally
identical to a local-variable read (`x`). The call-walk only inspected
`call` nodes, so `build(1)`, `self.build` and `build arg` all produced a
`calls` edge while the bare `build` produced nothing. A whole module of
mutually-calling helpers could end up with no internal edges at all.
The Ruby resolver already had a dedicated bare-call promotion path, but it
had nothing to promote because extraction never produced the call.
Fix: mirror Ruby's own disambiguation rule — a bare identifier is a send on
`self` unless a local or parameter of that name is in scope. `_ruby_local_names`
collects the bound names of a method body (parameters, assignments, block
params, `for` variables, `rescue` captures); a bare identifier not among them
is routed through the normal callee path as a non-member call, resolving
in-file (EXTRACTED) or becoming a raw_call the resolver can prove.
The collector is deliberately over-inclusive, so a missed binding only
suppresses a candidate call (fail-closed) rather than inventing a wrong edge.
A receiver-position send (`logger.info` where `logger` is itself a send) is
left for a follow-up and stays unresolved rather than guessed.
Added tests/test_ruby_bare_self_send_calls.py covering the self-send, the
cross-module and mixed-in cases, and the negatives (a local, a parameter and
a block parameter that shadow a method name must NOT become calls).
(cherry picked from commit c95b11c8e6)
179 lines
4.9 KiB
Python
179 lines
4.9 KiB
Python
"""A paren-less Ruby method call is a self-send and must keep its `calls` edge.
|
|
|
|
In Ruby the most common call form has no receiver and no parentheses::
|
|
|
|
def run
|
|
build # a method call on self, not a variable read
|
|
end
|
|
|
|
Tree-sitter parses that `build` as a bare `identifier`, structurally identical
|
|
to a local-variable read `x`. The call-walk only looked at `call` nodes, so the
|
|
edge was silently dropped — `run` → `build` never appeared, even though
|
|
`run(1)`, `self.run` and `run arg` all resolved. The Ruby resolver already had
|
|
a dedicated bare-call promotion path, but it had nothing to promote because the
|
|
extraction step never produced the call.
|
|
|
|
The fix mirrors Ruby's own rule: a bare identifier is a send on `self` unless a
|
|
local or parameter of that name is in scope. These specs pin both halves — the
|
|
send is captured, and a genuine local read is NOT turned into a call.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract
|
|
|
|
|
|
def _extract(tmp_path, files: dict[str, str]) -> dict:
|
|
for name, body in files.items():
|
|
p = tmp_path / name
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(body)
|
|
old = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
return extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp()))
|
|
finally:
|
|
os.chdir(old)
|
|
|
|
|
|
def _labels(r: dict) -> dict[str, str]:
|
|
return {n["id"]: str(n.get("label", "")) for n in r["nodes"]}
|
|
|
|
|
|
def _calls(r: dict, src: str, tgt: str) -> list[dict]:
|
|
labels = _labels(r)
|
|
return [
|
|
e
|
|
for e in r["edges"]
|
|
if e.get("relation") == "calls"
|
|
and labels.get(e.get("source")) == src
|
|
and labels.get(e.get("target")) == tgt
|
|
]
|
|
|
|
|
|
def test_bare_self_send_in_a_class_is_a_call(tmp_path):
|
|
r = _extract(tmp_path, {"w.rb": (
|
|
"class Widget\n"
|
|
" def run\n"
|
|
" build\n"
|
|
" end\n"
|
|
" def build\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert _calls(r, ".run()", ".build()")
|
|
|
|
|
|
def test_bare_call_between_module_methods_is_a_call(tmp_path):
|
|
# The whole module layer used to be a set of leaves with no internal edges.
|
|
r = _extract(tmp_path, {"g.rb": (
|
|
"module Greetable\n"
|
|
" def greet\n"
|
|
" build_message\n"
|
|
" end\n"
|
|
" def build_message\n"
|
|
" \"hi\"\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert _calls(r, ".greet()", ".build_message()")
|
|
|
|
|
|
def test_bare_call_to_a_mixed_in_method_resolves(tmp_path):
|
|
r = _extract(tmp_path, {"m.rb": (
|
|
"module Greetable\n"
|
|
" def greet\n"
|
|
" \"hi\"\n"
|
|
" end\n"
|
|
"end\n"
|
|
"class Person\n"
|
|
" include Greetable\n"
|
|
" def initialize\n"
|
|
" greet\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert _calls(r, ".initialize()", ".greet()")
|
|
|
|
|
|
def test_bare_call_as_an_argument_is_a_call(tmp_path):
|
|
# `log hello` — `hello` is itself a paren-less send passed as the argument.
|
|
r = _extract(tmp_path, {"a.rb": (
|
|
"class W\n"
|
|
" def run\n"
|
|
" log hello\n"
|
|
" end\n"
|
|
" def hello\n"
|
|
" end\n"
|
|
" def log(x)\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert _calls(r, ".run()", ".hello()")
|
|
|
|
|
|
def test_a_local_variable_read_is_not_a_call(tmp_path):
|
|
# `build` is assigned first, so the later bare `build` is a variable read,
|
|
# not a send — even though a method named `build` exists. No edge.
|
|
r = _extract(tmp_path, {"v.rb": (
|
|
"class W\n"
|
|
" def run\n"
|
|
" build = 1\n"
|
|
" build\n"
|
|
" end\n"
|
|
" def build\n"
|
|
" 2\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert not _calls(r, ".run()", ".build()")
|
|
|
|
|
|
def test_a_parameter_read_is_not_a_call(tmp_path):
|
|
# A parameter named like a method shadows it; the bare read is the parameter.
|
|
r = _extract(tmp_path, {"p.rb": (
|
|
"class W\n"
|
|
" def run(build)\n"
|
|
" build\n"
|
|
" end\n"
|
|
" def build\n"
|
|
" 1\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert not _calls(r, ".run()", ".build()")
|
|
|
|
|
|
def test_a_block_parameter_read_is_not_a_call(tmp_path):
|
|
# `item` is a block parameter inside the method, not a send.
|
|
r = _extract(tmp_path, {"b.rb": (
|
|
"class W\n"
|
|
" def run\n"
|
|
" [1].each do |item|\n"
|
|
" item\n"
|
|
" end\n"
|
|
" end\n"
|
|
" def item\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert not _calls(r, ".run()", ".item()")
|
|
|
|
|
|
def test_self_send_and_bare_send_do_not_duplicate_an_edge(tmp_path):
|
|
r = _extract(tmp_path, {"s.rb": (
|
|
"class W\n"
|
|
" def run\n"
|
|
" build\n"
|
|
" self.build\n"
|
|
" end\n"
|
|
" def build\n"
|
|
" end\n"
|
|
"end\n"
|
|
)})
|
|
assert len(_calls(r, ".run()", ".build()")) == 1
|