mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
fix(php): parse PHP 8.5 (void) casts and casted match expressions (#4202)
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 43dbd7457c)
This commit is contained in:
1 parent
deda9adb64
commit
b583288e95
2 files changed
+137
-1
No files matched your search
+36
-1
@@ -3159,6 +3159,37 @@ def _php_mask_to_script_blocks(src: str) -> tuple[str, bool]:
|
||||
return "".join(out), True
|
||||
|
||||
|
||||
# tree-sitter-php (the pinned <0.25 and 0.25.1 alike) has no PHP 8.5 `(void)`
|
||||
# cast and cannot parse a cast applied directly to `match (...)`: the statement
|
||||
# becomes an ERROR node, can lose its call, and the file is reported as partially
|
||||
# extracted (#4202). Blanking just the cast keeps the call or match after it.
|
||||
_PHP_UNPARSED_CAST_RE = re.compile(
|
||||
rb"\(\s*void\s*\)|\(\s*(?:string|int|integer|float|double|real|bool|boolean"
|
||||
rb"|array|object|binary)\s*\)(?=\s*match\s*\()",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PHP_NON_CODE = frozenset({"comment", "string", "encapsed_string", "heredoc", "nowdoc", "text"})
|
||||
|
||||
|
||||
def _php_blank_unparsed_casts(source: bytes) -> bytes:
|
||||
"""Blank `(void)` casts and casts on `match` to spaces, outside strings and
|
||||
comments, keeping every byte offset and line number (#4202)."""
|
||||
matches = list(_PHP_UNPARSED_CAST_RE.finditer(source))
|
||||
if not matches:
|
||||
return source
|
||||
import tree_sitter_php
|
||||
from tree_sitter import Language, Parser
|
||||
root = Parser(Language(tree_sitter_php.language_php())).parse(source).root_node
|
||||
out = bytearray(source)
|
||||
for m in matches:
|
||||
node = root.descendant_for_byte_range(m.start(), m.start() + 1)
|
||||
while node is not None and node.type not in _PHP_NON_CODE:
|
||||
node = node.parent
|
||||
if node is None:
|
||||
out[m.start():m.end()] = re.sub(rb"[^\r\n]", b" ", m.group())
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def extract_php(path: Path) -> dict:
|
||||
"""Extract classes, functions, methods, namespace uses, and calls from a .php file.
|
||||
|
||||
@@ -3173,7 +3204,11 @@ def extract_php(path: Path) -> dict:
|
||||
dropped JS node would have used, so no edge from the discarded symbol
|
||||
is left to silently attach to the unrelated PHP node sharing its id.
|
||||
"""
|
||||
result = _extract_generic(path, _PHP_CONFIG)
|
||||
try:
|
||||
source = _php_blank_unparsed_casts(_read_source_bytes(path))
|
||||
except Exception:
|
||||
source = None # let _extract_generic read the file and report errors
|
||||
result = _extract_generic(path, _PHP_CONFIG, source_override=source)
|
||||
try:
|
||||
src = _read_source_text(path, warn=False)
|
||||
masked, had_script = _php_mask_to_script_blocks(src)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""PHP 8.5 `(void)` casts and casts applied to `match (...)` (#4202).
|
||||
|
||||
tree-sitter-php has no `(void)` cast and cannot parse `(string) match (...)`,
|
||||
so those statements came out as ERROR nodes: the call behind the cast was lost
|
||||
and the file got a partial-extraction warning. extract_php blanks just the cast
|
||||
(same length, code only) before parsing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import (
|
||||
_PHP_CONFIG,
|
||||
_extract_generic,
|
||||
_php_blank_unparsed_casts,
|
||||
extract,
|
||||
extract_php,
|
||||
)
|
||||
|
||||
CACHE = (
|
||||
"<?php\nnamespace App;\n"
|
||||
"class Cache {\n public static function warm(string $k): bool { return true; }\n}\n"
|
||||
)
|
||||
|
||||
|
||||
def _extract(tmp_path, files: dict[str, str]):
|
||||
for name, body in files.items():
|
||||
(tmp_path / name).write_text(body, encoding="utf-8")
|
||||
r = extract([tmp_path / n for n in files], cache_root=Path(tempfile.mkdtemp()),
|
||||
root=tmp_path, parallel=False)
|
||||
labels = {n["id"]: n["label"] for n in r["nodes"]}
|
||||
calls = {(labels[e["source"]], labels[e["target"]]) for e in r["edges"]
|
||||
if e["relation"] == "calls"}
|
||||
return calls, r
|
||||
|
||||
|
||||
KERNEL = (
|
||||
"<?php\nnamespace App;\nclass Kernel {\n"
|
||||
" public function boot(): void {\n"
|
||||
" {cast}Cache::warm('routes');\n"
|
||||
" {cast}$this->ready();\n"
|
||||
" }\n"
|
||||
" private function ready(): void {}\n}\n"
|
||||
)
|
||||
|
||||
|
||||
def test_void_cast_keeps_the_call_and_parses_cleanly(tmp_path):
|
||||
"""`(void) X::m()` yields the same call edges as plain `X::m()`."""
|
||||
(tmp_path / "a").mkdir()
|
||||
(tmp_path / "b").mkdir()
|
||||
calls, _ = _extract(tmp_path / "a", {"Cache.php": CACHE,
|
||||
"Kernel.php": KERNEL.replace("{cast}", "(void) ")})
|
||||
plain, _ = _extract(tmp_path / "b", {"Cache.php": CACHE,
|
||||
"Kernel.php": KERNEL.replace("{cast}", "")})
|
||||
assert (".boot()", ".ready()") in calls
|
||||
assert any(s == ".boot()" and t != ".ready()" for s, t in calls) # the Cache::warm call
|
||||
assert calls == plain
|
||||
assert "parse_errors" not in extract_php(tmp_path / "a" / "Kernel.php")
|
||||
|
||||
|
||||
def test_cast_on_match_parses_and_keeps_what_follows(tmp_path):
|
||||
calls, _ = _extract(tmp_path, {"Picker.php": (
|
||||
"<?php\nnamespace App;\nclass Picker {\n"
|
||||
" public function label(int $mode): string {\n"
|
||||
" return (string) match ($mode) {\n"
|
||||
" 1 => $this->first(),\n"
|
||||
" default => $this->second(),\n"
|
||||
" };\n"
|
||||
" }\n"
|
||||
" public function first(): int { return 1; }\n"
|
||||
" public function second(): int { return 2; }\n"
|
||||
" public function after(): string { return $this->label(1); }\n}\n")})
|
||||
assert {(".label()", ".first()"), (".label()", ".second()"),
|
||||
(".after()", ".label()")} <= calls
|
||||
assert "parse_errors" not in extract_php(tmp_path / "Picker.php")
|
||||
|
||||
|
||||
def test_strings_and_comments_are_never_blanked():
|
||||
src = (
|
||||
b"<?php\n// (void) foo();\n/* (string) match (x) */\n"
|
||||
b"$a = '(void) Foo::bar();';\n$b = \"(int) match (1) {$a}\";\n"
|
||||
b"$c = <<<TXT\n(void) Heredoc::thing();\nTXT;\n"
|
||||
b"$d = <<<'RAW'\n(bool) match (y) {}\nRAW;\n?>\n<p>(void) html</p>\n"
|
||||
)
|
||||
assert _php_blank_unparsed_casts(src) == src
|
||||
|
||||
|
||||
def test_blanking_keeps_offsets_and_line_numbers():
|
||||
src = b"<?php\n(\n VOID\n) foo();\n$x = (string)\n match (1) { 1 => 2 };\n"
|
||||
out = _php_blank_unparsed_casts(src)
|
||||
assert len(out) == len(src)
|
||||
assert [i for i, ch in enumerate(out) if ch == 10] == [i for i, ch in enumerate(src) if ch == 10]
|
||||
assert b"VOID" not in out and b"(string)" not in out and b"match (1)" in out
|
||||
|
||||
|
||||
def test_files_without_the_pattern_extract_exactly_as_before(tmp_path):
|
||||
p = tmp_path / "Plain.php"
|
||||
p.write_text("<?php\nclass Plain {\n public function a(): int { return (int) '3'; }\n}\n",
|
||||
encoding="utf-8")
|
||||
assert extract_php(p) == _extract_generic(p, _PHP_CONFIG)
|
||||
Reference in new issue
Block a user