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:
Mahaboob pasha authored and safishamsi committed 2026-10-08 14:27:06 +01:00
1 parent deda9adb64
commit b583288e95
2 files changed
+137 -1

No files matched your search

+36 -1
View File
@@ -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)
+101
View File
@@ -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)