Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1400,7 +1400,7 @@ def _python_collect_assignment_targets(node, source: bytes, out: set[str]) -> No
# Languages whose `self`/`this` member calls bind through _self_call_target.
_SELF_CALL_LANGUAGES = frozenset({
"tree_sitter_python", "tree_sitter_javascript", "tree_sitter_typescript",
"tree_sitter_swift", "tree_sitter_ruby",
"tree_sitter_swift", "tree_sitter_ruby", "tree_sitter_php",
})

def _self_call_target(
Expand All @@ -1413,9 +1413,9 @@ def _self_call_target(
methods_by_owner: dict[tuple[str, str], str],
class_bases: dict[str, list[str]],
walk_bases: bool = True,
require_method_owner: bool = False,
) -> str | None:
"""In-file target of `self.m()` / `cls.m()` / `super().m()` in Python and
`this.m()` / `super.m()` in JS/TS, else None.
"""In-file target of a known current-instance or base-instance method call.

``walk_bases=False`` stops after the caller's own class and otherwise keeps
the plain lookup: JS/TS `extends` edges come from the later symbol pass, so
Expand All @@ -1436,7 +1436,7 @@ class to whichever class declared save() last. A file-wide hit that is not
scope = scope_parents.get(scope)
fallback = label_to_nid.get(callee)
if not scope:
return fallback
return None if require_method_owner else fallback
level = [method_owner[scope]]
seen: set[str] = set()
skip_own = receiver == "super"
Expand Down Expand Up @@ -6532,6 +6532,8 @@ def scala_base_name(type_node) -> str | None:
if e["relation"] == "method":
method_owner[e["target"]] = e["source"]
name = label_by_nid.get(e["target"], "").strip("()").lstrip(".")
if config.ts_module == "tree_sitter_php":
name = name.casefold()
methods_by_owner.setdefault((e["source"], name), e["target"])

def _fields_up_chain(tables: dict, class_nid) -> dict:
Expand Down Expand Up @@ -7099,6 +7101,12 @@ def walk_calls(
name_node = node.child_by_field_name("name")
if name_node:
callee_name = _read_text(name_node, source)
receiver = node.child_by_field_name("object")
if (receiver is not None
and _read_text(receiver, source) == "$this"
and callee_name
and callee_name.casefold() in _LANGUAGE_BUILTIN_GLOBALS):
self_receiver = "this"
elif config.ts_module == "tree_sitter_cpp":
# C++: function field, then field_expression/qualified_identifier
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
Expand Down Expand Up @@ -7288,7 +7296,9 @@ def walk_calls(
# receiver-typed defers just past this comment) means it can only ever
# reach an edge through a guarded, receiver-typed resolver, never the
# unguarded bare-name path a real god node would need.
# A known PHP $this receiver resolves against its owning class's methods.
_builtin_member_call = is_member_call and callee_name in _LANGUAGE_BUILTIN_GLOBALS
php_builtin_self_call = config.ts_module == "tree_sitter_php" and self_receiver == "this"
if callee_name and (
callee_name not in _LANGUAGE_BUILTIN_GLOBALS or _builtin_member_call
):
Expand Down Expand Up @@ -7329,7 +7339,7 @@ def walk_calls(
and is_member_call
and not lua_self_qualified
)
if _python_defer or _java_defer or _builtin_member_call or _lua_member_defer or (
if _python_defer or _java_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or (
is_member_call
and member_receiver
and (
Expand All @@ -7356,13 +7366,17 @@ def walk_calls(
and member_receiver in ("self", "cls", "super")
):
tgt_nid = _self_call_target(
caller_nid, callee_name, self_receiver or member_receiver or "",
caller_nid, callee_name.casefold() if php_builtin_self_call else callee_name,
self_receiver or member_receiver or "",
label_to_nid, scope_parents, method_owner, methods_by_owner,
_local_bases,
walk_bases=config.ts_module not in (
"tree_sitter_javascript", "tree_sitter_typescript",
),
require_method_owner=php_builtin_self_call,
)
if php_builtin_self_call and tgt_nid not in method_owner:
tgt_nid = None
else:
tgt_nid = label_to_nid.get(callee_name)
# A qualified `new A.B.Foo()` whose bare name matches only a
Expand Down
97 changes: 97 additions & 0 deletions tests/test_php_language_construct_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
reason: `$bag->empty()` is a genuine member call into that method and must still
resolve, and the construct's arguments must still be walked for calls.
"""
import pytest

from graphify.extract import extract


Expand Down Expand Up @@ -256,3 +258,98 @@ def test_non_construct_function_call_still_resolves(tmp_path):

resolved = _edges_between(result, caller_ids, target_ids)
assert resolved, "a genuine cross-file function call must still resolve"


@pytest.mark.parametrize("name, statement", [
("die", "die($value);"),
("eval", "eval($value);"),
("array", "$result = array($value);"),
("exit", "exit($value);"),
("list", "list($value) = $values;"),
("unset", "unset($value);"),
])
def test_construct_named_methods_keep_only_real_member_calls(tmp_path, name, statement):
"""Keyword use must not bind, while the same-named member remains callable."""
(tmp_path / "Bag.php").write_text(
"<?php\nclass Bag {\n"
f" public function {name}($value) {{ return $value; }}\n"
f" public function constructUse($value, $values) {{ {statement} }}\n"
f" public function memberUse($value) {{ return $this->{name}($value); }}\n"
"}\n",
encoding="utf-8",
)
(tmp_path / "outside.php").write_text(
f"<?php\nfunction externalUse($value, $values) {{ {statement} }}\n",
encoding="utf-8",
)
result = _extract_php(tmp_path)
target = _ids(result, "Bag.php", name)
local = _ids(result, "Bag.php", "constructUse")
external = _ids(result, "outside.php", "externalUse")
member = _ids(result, "Bag.php", "memberUse")
assert target and local and external and member
assert not _edges_between(result, local | external, target)
assert _edges_between(result, member, target)


def test_builtin_named_self_method_uses_its_own_class_and_php_case_rules(tmp_path):
source = tmp_path / "Bag.php"
source.write_text(
"<?php\nclass Bag { public function list() {}\n"
" public function useList() { $this->LIST(); } }\n"
"class Other { public function list() {} }\n",
encoding="utf-8",
)
result = _extract_php(tmp_path)
bag = next(n["id"] for n in result["nodes"] if n["label"] == "Bag")
other = next(n["id"] for n in result["nodes"] if n["label"] == "Other")
targets = _ids(result, "Bag.php", "list")
bag_targets = {e["target"] for e in result["edges"]
if e["source"] == bag and e["relation"] == "method"} & targets
other_targets = {e["target"] for e in result["edges"]
if e["source"] == other and e["relation"] == "method"} & targets
caller = _ids(result, "Bag.php", "useList")
assert bag_targets and other_targets and caller
assert _edges_between(result, caller, bag_targets)
assert not _edges_between(result, caller, other_targets)


def test_builtin_named_self_call_does_not_bind_to_another_class(tmp_path):
(tmp_path / "Bag.php").write_text(
"<?php\nclass Bag { public function useList() { $this->list(); } }\n"
"class Other { public function list() {} }\n",
encoding="utf-8",
)
result = _extract_php(tmp_path)
caller = _ids(result, "Bag.php", "useList")
target = _ids(result, "Bag.php", "list")
assert caller and target
assert not _edges_between(result, caller, target)


def test_builtin_named_self_call_does_not_bind_to_a_free_function(tmp_path):
(tmp_path / "Bag.php").write_text(
"<?php\nfunction open() {}\n"
"class Bag { public function useOpen() { $this->open(); } }\n",
encoding="utf-8",
)
result = _extract_php(tmp_path)
caller = _ids(result, "Bag.php", "useOpen")
target = _ids(result, "Bag.php", "open")
assert caller and target
assert not _edges_between(result, caller, target)


def test_builtin_named_this_call_without_a_class_owner_stays_unresolved(tmp_path):
(tmp_path / "Bag.php").write_text(
"<?php\nfunction useList() { $this->list(); }\n"
"function makeListCallable() { return function() { $this->list(); }; }\n"
"class Other { public function list() {} }\n",
encoding="utf-8",
)
result = _extract_php(tmp_path)
target = _ids(result, "Bag.php", "list")
assert target and _ids(result, "Bag.php", "useList")
assert _ids(result, "Bag.php", "makeListCallable")
assert not any(e["relation"] == "calls" and e["target"] in target
for e in result["edges"])
Loading