Skip to content
Open
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
71 changes: 65 additions & 6 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,14 +727,55 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st
break


def _php_import_kind(node) -> str | None:
kinds = ("function", "const")
local_kind = next((child.type for child in node.children if child.type in kinds), None)
if local_kind:
return local_kind

parent = node.parent
declaration = parent.parent if parent is not None and parent.type == "namespace_use_group" else parent
if declaration is None or declaration.type != "namespace_use_declaration":
return None

declaration_kind = next(
(child.type for child in declaration.children if child.type in kinds),
None,
)
if declaration_kind:
return declaration_kind

first_clause = next(
(child for child in declaration.children if child.type == "namespace_use_clause"),
None,
)
if first_clause is None:
return None
return next((child.type for child in first_clause.children if child.type in kinds), None)


def _php_import_fqn(node, source: bytes, raw: str) -> str:
parent = node.parent
if parent is not None and parent.type == "namespace_use_group":
declaration = parent.parent
if declaration is not None:
prefix = next(
(_read_text(child, source) for child in declaration.children
if child.type == "namespace_name"),
"",
)
if prefix:
raw = f"{prefix}\\{raw}"
return raw.lstrip("\\")

def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
for child in node.children:
if child.type in ("qualified_name", "name", "identifier"):
raw = _read_text(child, source)
module_name = raw.split("\\")[-1].strip()
if module_name:
tgt_nid = _make_id(module_name)
edges.append({
edge = {
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
Expand All @@ -743,7 +784,15 @@ def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
}
kind = _php_import_kind(node)
if kind in ("function", "const"):
edge["_php_symbol_import"] = True
elif kind is None:
target_fqn = _php_import_fqn(node, source, raw)
if target_fqn:
edge["metadata"] = sanitize_metadata({"target_fqn": target_fqn})
edges.append(edge)
break


Expand Down Expand Up @@ -2415,9 +2464,16 @@ def extract_scala(path: Path) -> dict:
return _extract_generic(path, _SCALA_CONFIG)


def _extract_php_with_symbol_markers(path: Path) -> dict:
return _extract_generic(path, _PHP_CONFIG)


def extract_php(path: Path) -> dict:
"""Extract classes, functions, methods, namespace uses, and calls from a .php file."""
return _extract_generic(path, _PHP_CONFIG)
result = _extract_php_with_symbol_markers(path)
for edge in result.get("edges", []):
edge.pop("_php_symbol_import", None)
return result


# One level of balanced parens (e.g. `Foo #(Bar #(int))`) — bounded so malformed
Expand Down Expand Up @@ -2721,6 +2777,8 @@ def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
remap[stub_id] = target_id

if not remap:
for edge in edges:
edge.pop("_php_symbol_import", None)
return

by_id = {node.get("id"): node for node in nodes if node.get("id")}
Expand Down Expand Up @@ -2770,7 +2828,8 @@ def _names_own_builtin_base(edge: dict, stub_id: str, remapped_id: str) -> bool:
):
edge["source"] = remapped_source
target = edge.get("target")
if target in remap:
is_php_symbol_import = bool(edge.pop("_php_symbol_import", False))
if target in remap and not is_php_symbol_import:
remapped_target = remap[str(target)]
if not (
is_csharp_scoped_edge
Expand Down Expand Up @@ -5715,7 +5774,7 @@ def add_existing_edge(edge: dict) -> None:
".kt": extract_kotlin,
".kts": extract_kotlin,
".scala": extract_scala,
".php": extract_php,
".php": _extract_php_with_symbol_markers,
".swift": extract_swift,
".lua": extract_lua,
".luau": extract_lua,
Expand Down Expand Up @@ -5840,7 +5899,7 @@ def add_existing_edge(edge: dict) -> None:
"nodejs": extract_js,
"ruby": extract_ruby,
"lua": extract_lua,
"php": extract_php,
"php": _extract_php_with_symbol_markers,
"julia": extract_julia,
}

Expand Down
73 changes: 67 additions & 6 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -3110,6 +3110,15 @@ def _external_stub(fqn: str) -> str:


_PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in")
_PHP_SOURCE_SUFFIXES = (".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps")


def _is_php_source(source_file: object) -> bool:
"""Did a PHP file produce this edge? Mirrors the suffix set extract.py selects on."""
name = str(source_file or "").lower()
return name.endswith(_PHP_SOURCE_SUFFIXES) and not name.endswith(".blade.php")


_PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"})


Expand Down Expand Up @@ -3197,14 +3206,19 @@ def _record_raw(relation: str, raw: str) -> None:
else:
raws.setdefault(key, raw)

def _record_use_clause(clause, prefix: str) -> None:
def _record_use_clause(
clause,
prefix: str,
declaration_kind: str | None = None,
) -> None:
clause_kind = declaration_kind
target = None
alias = None
saw_as = False
for c in clause.children:
if c.type in ("function", "const"):
return # not a class import
if c.type == "as":
clause_kind = c.type
elif c.type == "as":
saw_as = True
elif c.type in ("qualified_name", "name"):
if saw_as:
Expand All @@ -3215,6 +3229,8 @@ def _record_use_clause(clause, prefix: str) -> None:
return
fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\")
key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower()
if clause_kind in ("function", "const"):
return
if key:
uses.setdefault(key, fqn)

Expand All @@ -3228,17 +3244,35 @@ def walk(n) -> None:
elif t == "namespace_use_declaration":
prefix = ""
group = None
declaration_kind = next(
(c.type for c in n.children if c.type in ("function", "const")),
None,
)
if declaration_kind is None:
first_clause = next(
(c for c in n.children if c.type == "namespace_use_clause"),
None,
)
if first_clause is not None:
declaration_kind = next(
(
c.type
for c in first_clause.children
if c.type in ("function", "const")
),
None,
)
for c in n.children:
if c.type == "namespace_name":
prefix = _read_text(c, source) # group-use prefix
elif c.type == "namespace_use_group":
group = c
elif c.type == "namespace_use_clause":
_record_use_clause(c, "")
_record_use_clause(c, "", declaration_kind)
if group is not None:
for c in group.children:
if c.type == "namespace_use_clause":
_record_use_clause(c, prefix)
_record_use_clause(c, prefix, declaration_kind)
return
elif t == "class_declaration":
for child in n.children:
Expand Down Expand Up @@ -3320,9 +3354,36 @@ def _external_stub(fqn: str) -> str:
if relation not in _PHP_REPOINT_RELATIONS:
continue
ref_file = edge.get("source_file", "")
tgt = edge.get("target")
metadata = edge.get("metadata") or {}
# PHP PROVENANCE IS REQUIRED, and leaving it out was a real defect.
#
# `metadata.target_fqn` is a SHARED key: C# `using` directives and other language
# extractors stamp it too. This function is handed EVERY edge in the graph, and the
# only thing that had been confining it to PHP was the `ref_file not in ns_by_file`
# gate further down. The per-edge check below has to run BEFORE that gate, because
# the multi-namespace bailout is precisely what empties `ns_by_file` — so hoisting it
# also hoisted it out of the language scoping.
#
# Measured before this line existed: `import external.lib.Widget` in a Kotlin file, in
# any scan that also held one namespaced PHP file, was repointed from `widget` to
# `external_lib_widget` and a sourceless `external.lib.Widget` node was invented.
imported_fqn = (
metadata.get("target_fqn")
if relation == "imports" and isinstance(metadata, dict)
and _is_php_source(ref_file)
else None
)
if isinstance(imported_fqn, str) and imported_fqn:
resolved = fqn_to_id.get(imported_fqn.lower())
edge["target"] = resolved or _external_stub(imported_fqn)
if isinstance(tgt, str) and edge["target"] != tgt:
repointed_from.add(tgt)
continue
if ref_file not in ns_by_file:
continue
tgt = edge.get("target")
if relation == "imports" and edge.get("_php_symbol_import"):
continue
label = stub_label.get(tgt)
uses = uses_by_file.get(ref_file, {})
if not label and relation == "imports":
Expand Down
Loading