Skip to content

Commit 43dbd74

Browse files
Mpasha17Grok Bot
andcommitted
fix(php): parse PHP 8.5 (void) casts and casted match expressions (#4202)
Co-Authored-By: Grok Bot <noreply@x.ai>
1 parent 6478eb7 commit 43dbd74

2 files changed

Lines changed: 137 additions & 1 deletion

File tree

‎graphify/extract.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3159,6 +3159,37 @@ def _blank(s: str) -> str:
31593159
return "".join(out), True
31603160

31613161

3162+
# tree-sitter-php (the pinned <0.25 and 0.25.1 alike) has no PHP 8.5 `(void)`
3163+
# cast and cannot parse a cast applied directly to `match (...)`: the statement
3164+
# becomes an ERROR node, can lose its call, and the file is reported as partially
3165+
# extracted (#4202). Blanking just the cast keeps the call or match after it.
3166+
_PHP_UNPARSED_CAST_RE = re.compile(
3167+
rb"\(\s*void\s*\)|\(\s*(?:string|int|integer|float|double|real|bool|boolean"
3168+
rb"|array|object|binary)\s*\)(?=\s*match\s*\()",
3169+
re.IGNORECASE,
3170+
)
3171+
_PHP_NON_CODE = frozenset({"comment", "string", "encapsed_string", "heredoc", "nowdoc", "text"})
3172+
3173+
3174+
def _php_blank_unparsed_casts(source: bytes) -> bytes:
3175+
"""Blank `(void)` casts and casts on `match` to spaces, outside strings and
3176+
comments, keeping every byte offset and line number (#4202)."""
3177+
matches = list(_PHP_UNPARSED_CAST_RE.finditer(source))
3178+
if not matches:
3179+
return source
3180+
import tree_sitter_php
3181+
from tree_sitter import Language, Parser
3182+
root = Parser(Language(tree_sitter_php.language_php())).parse(source).root_node
3183+
out = bytearray(source)
3184+
for m in matches:
3185+
node = root.descendant_for_byte_range(m.start(), m.start() + 1)
3186+
while node is not None and node.type not in _PHP_NON_CODE:
3187+
node = node.parent
3188+
if node is None:
3189+
out[m.start():m.end()] = re.sub(rb"[^\r\n]", b" ", m.group())
3190+
return bytes(out)
3191+
3192+
31623193
def extract_php(path: Path) -> dict:
31633194
"""Extract classes, functions, methods, namespace uses, and calls from a .php file.
31643195
@@ -3173,7 +3204,11 @@ def extract_php(path: Path) -> dict:
31733204
dropped JS node would have used, so no edge from the discarded symbol
31743205
is left to silently attach to the unrelated PHP node sharing its id.
31753206
"""
3176-
result = _extract_generic(path, _PHP_CONFIG)
3207+
try:
3208+
source = _php_blank_unparsed_casts(_read_source_bytes(path))
3209+
except Exception:
3210+
source = None # let _extract_generic read the file and report errors
3211+
result = _extract_generic(path, _PHP_CONFIG, source_override=source)
31773212
try:
31783213
src = _read_source_text(path, warn=False)
31793214
masked, had_script = _php_mask_to_script_blocks(src)

‎tests/test_php_unparsed_casts.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""PHP 8.5 `(void)` casts and casts applied to `match (...)` (#4202).
2+
3+
tree-sitter-php has no `(void)` cast and cannot parse `(string) match (...)`,
4+
so those statements came out as ERROR nodes: the call behind the cast was lost
5+
and the file got a partial-extraction warning. extract_php blanks just the cast
6+
(same length, code only) before parsing.
7+
"""
8+
from __future__ import annotations
9+
10+
import tempfile
11+
from pathlib import Path
12+
13+
from graphify.extract import (
14+
_PHP_CONFIG,
15+
_extract_generic,
16+
_php_blank_unparsed_casts,
17+
extract,
18+
extract_php,
19+
)
20+
21+
CACHE = (
22+
"<?php\nnamespace App;\n"
23+
"class Cache {\n public static function warm(string $k): bool { return true; }\n}\n"
24+
)
25+
26+
27+
def _extract(tmp_path, files: dict[str, str]):
28+
for name, body in files.items():
29+
(tmp_path / name).write_text(body, encoding="utf-8")
30+
r = extract([tmp_path / n for n in files], cache_root=Path(tempfile.mkdtemp()),
31+
root=tmp_path, parallel=False)
32+
labels = {n["id"]: n["label"] for n in r["nodes"]}
33+
calls = {(labels[e["source"]], labels[e["target"]]) for e in r["edges"]
34+
if e["relation"] == "calls"}
35+
return calls, r
36+
37+
38+
KERNEL = (
39+
"<?php\nnamespace App;\nclass Kernel {\n"
40+
" public function boot(): void {\n"
41+
" {cast}Cache::warm('routes');\n"
42+
" {cast}$this->ready();\n"
43+
" }\n"
44+
" private function ready(): void {}\n}\n"
45+
)
46+
47+
48+
def test_void_cast_keeps_the_call_and_parses_cleanly(tmp_path):
49+
"""`(void) X::m()` yields the same call edges as plain `X::m()`."""
50+
(tmp_path / "a").mkdir()
51+
(tmp_path / "b").mkdir()
52+
calls, _ = _extract(tmp_path / "a", {"Cache.php": CACHE,
53+
"Kernel.php": KERNEL.replace("{cast}", "(void) ")})
54+
plain, _ = _extract(tmp_path / "b", {"Cache.php": CACHE,
55+
"Kernel.php": KERNEL.replace("{cast}", "")})
56+
assert (".boot()", ".ready()") in calls
57+
assert any(s == ".boot()" and t != ".ready()" for s, t in calls) # the Cache::warm call
58+
assert calls == plain
59+
assert "parse_errors" not in extract_php(tmp_path / "a" / "Kernel.php")
60+
61+
62+
def test_cast_on_match_parses_and_keeps_what_follows(tmp_path):
63+
calls, _ = _extract(tmp_path, {"Picker.php": (
64+
"<?php\nnamespace App;\nclass Picker {\n"
65+
" public function label(int $mode): string {\n"
66+
" return (string) match ($mode) {\n"
67+
" 1 => $this->first(),\n"
68+
" default => $this->second(),\n"
69+
" };\n"
70+
" }\n"
71+
" public function first(): int { return 1; }\n"
72+
" public function second(): int { return 2; }\n"
73+
" public function after(): string { return $this->label(1); }\n}\n")})
74+
assert {(".label()", ".first()"), (".label()", ".second()"),
75+
(".after()", ".label()")} <= calls
76+
assert "parse_errors" not in extract_php(tmp_path / "Picker.php")
77+
78+
79+
def test_strings_and_comments_are_never_blanked():
80+
src = (
81+
b"<?php\n// (void) foo();\n/* (string) match (x) */\n"
82+
b"$a = '(void) Foo::bar();';\n$b = \"(int) match (1) {$a}\";\n"
83+
b"$c = <<<TXT\n(void) Heredoc::thing();\nTXT;\n"
84+
b"$d = <<<'RAW'\n(bool) match (y) {}\nRAW;\n?>\n<p>(void) html</p>\n"
85+
)
86+
assert _php_blank_unparsed_casts(src) == src
87+
88+
89+
def test_blanking_keeps_offsets_and_line_numbers():
90+
src = b"<?php\n(\n VOID\n) foo();\n$x = (string)\n match (1) { 1 => 2 };\n"
91+
out = _php_blank_unparsed_casts(src)
92+
assert len(out) == len(src)
93+
assert [i for i, ch in enumerate(out) if ch == 10] == [i for i, ch in enumerate(src) if ch == 10]
94+
assert b"VOID" not in out and b"(string)" not in out and b"match (1)" in out
95+
96+
97+
def test_files_without_the_pattern_extract_exactly_as_before(tmp_path):
98+
p = tmp_path / "Plain.php"
99+
p.write_text("<?php\nclass Plain {\n public function a(): int { return (int) '3'; }\n}\n",
100+
encoding="utf-8")
101+
assert extract_php(p) == _extract_generic(p, _PHP_CONFIG)

0 commit comments

Comments
 (0)