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
84 changes: 84 additions & 0 deletions graphify/extractors/php_normalize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Byte-preserving rewrites for PHP syntax the pinned grammar rejects.

tree-sitter-php 0.24/0.25 ``cast_type`` has no ``void``, and ``cast_expression``
only accepts a unary/include/error-suppression operand. A PHP 8.5 ``(void)``
statement and ``(string) match { ... }`` both become ERROR nodes. The ERROR
swallows the call and, when recovery is unlucky, the enclosing declaration
(#4202). Blanking the unsupported cast keeps byte offsets and leaves a
statement the grammar already parses. ``: void`` return types are a different
production and must not be touched.
"""
from __future__ import annotations

import re

_PHP_CAST_WORD_RE = re.compile(
rb"[ \t]*(array|binary|bool|boolean|double|float|int|integer|object|real|string|unset|void)[ \t]*",
re.IGNORECASE,
)
_PHP_WS = b" \t\r\n"
_PHP_NL = (10, 13)


def normalize_php_85(source: bytes) -> bytes:
"""Blank PHP 8.5 casts the pinned grammar cannot parse (#4202).

* ``(void) expr;`` — the cast is a statement; the expression remains.
* ``(string) match`` — only the cast token is blanked. ``(string) (match
...)`` already parses and is not rewritten.

Strings and comments are skipped so a literal ``'(void)'`` is unchanged.
"""
out = bytearray(source)
n = len(source)
i = 0
changed = False
while i < n:
c = source[i]
if c == 47 and i + 1 < n and source[i + 1] == 47:
i += 2
while i < n and source[i] not in _PHP_NL:
i += 1
continue
if c == 35 and (i == 0 or source[i - 1] in _PHP_WS or source[i - 1] in b";{}()"):
i += 1
while i < n and source[i] not in _PHP_NL:
i += 1
continue
if c == 47 and i + 1 < n and source[i + 1] == 42:
i += 2
while i + 1 < n and not (source[i] == 42 and source[i + 1] == 47):
i += 1
i = min(n, i + 2)
continue
if c in (39, 34):
quote = c
i += 1
while i < n:
if source[i] == 92:
i += 2
continue
if source[i] == quote:
i += 1
break
i += 1
continue
if c == 40:
word = _PHP_CAST_WORD_RE.match(source, i + 1)
if word is not None and word.end() < n and source[word.end()] == 41:
cast_end = word.end() + 1
j = cast_end
while j < n and source[j] in _PHP_WS:
j += 1
is_void = word.group(1).lower() == b"void"
rest = source[j + 5:j + 6]
is_match = source.startswith(b"match", j) and not (rest.isalnum() or rest == b"_")
if is_void or is_match:
for k in range(i, cast_end):
if out[k] not in _PHP_NL:
out[k] = 32
changed = True
i = cast_end
continue
i += 1
return bytes(out) if changed else source
159 changes: 159 additions & 0 deletions tests/test_php_85_void_cast_match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""PHP 8.5 `(void)` casts and cast match arms must still extract (#4202).

tree-sitter-php 0.24/0.25 has no `void` cast_type, and a cast operand may only
be a unary/include/error-suppression expression. `(void) SomeClass::method()`
therefore becomes an ERROR (often on the class name), and
`return (string) match { Enum::Case => ... }` drops the arms. When recovery
swallows the declaration the file contributes no symbols. Blanking the cast
before the parse is the fix; suppressing the warning is not.
"""
from __future__ import annotations

import tempfile
from pathlib import Path

from graphify.extract import extract, extract_php


def _labels(result):
return {n["label"] for n in result["nodes"]}


def _calls(result):
labels = {n["id"]: n["label"] for n in result["nodes"]}
return {
(labels[e["source"]], labels[e["target"]])
for e in result["edges"]
if e["relation"] == "calls"
}


def _write(tmp_path: Path, name: str, body: str) -> Path:
path = tmp_path / name
path.write_text(body, encoding="utf-8")
return path


def test_void_cast_keeps_class_method_and_static_call(tmp_path):
"""`(void) SomeClass::someMethod(...)` must not erase the enclosing symbols."""
path = _write(tmp_path, "Widget.php", """<?php
namespace App;

class SomeClass {
public static function someMethod($argument): void {}
}

class Widget {
public function go($argument): void {
(void) SomeClass::someMethod($argument);
}
}
""")
result = extract_php(path)
assert result.get("parse_errors") is None
assert "Widget" in _labels(result)
assert ".go()" in _labels(result)
assert (".go()", "SomeClass") in _calls(result)


def test_cast_match_keeps_arm_calls(tmp_path):
"""`return (string) match { Enum::Case => $object->first() }` keeps the arms."""
path = _write(tmp_path, "Mode.php", """<?php
namespace App;

class Mode {
public function run($mode): string {
return (string) match ($mode) {
SomeEnum::First => $this->first(),
SomeEnum::Second => $this->second(),
};
}

public function first(): string { return "a"; }
public function second(): string { return "b"; }
}
""")
result = extract_php(path)
assert result.get("parse_errors") is None
assert "Mode" in _labels(result)
assert ".run()" in _labels(result)
assert (".run()", ".first()") in _calls(result)
assert (".run()", ".second()") in _calls(result)


def test_void_return_type_is_not_rewritten(tmp_path):
"""`: void` is a return type, not a cast, and must still yield the method."""
path = _write(tmp_path, "Task.php", """<?php
namespace App;

class Task {
public function run(): void {
$this->work();
}

public function work(): void {}
}
""")
result = extract_php(path)
assert result.get("parse_errors") is None
labels = _labels(result)
assert "Task" in labels
assert ".run()" in labels
assert ".work()" in labels
assert (".run()", ".work()") in _calls(result)


def test_void_literal_in_string_does_not_hide_the_real_cast(tmp_path):
path = _write(tmp_path, "Keep.php", """<?php
namespace App;

class Other {
public static function ping($argument): void {}
}

class Keep {
public function run($argument): void {
$note = '(void)';
(void) Other::ping($argument);
}
}
""")
result = extract_php(path)
assert result.get("parse_errors") is None
assert "Keep" in _labels(result)
assert ".run()" in _labels(result)
assert (".run()", "Other") in _calls(result)


def test_issue_snippets_survive_corpus_extract(tmp_path):
"""The two snippets from #4202 still produce symbols through extract()."""
files = {
"Void.php": """<?php
namespace App;
class SomeClass { public static function someMethod($argument): void {} }
class VoidDemo {
public function go($argument): void {
(void) SomeClass::someMethod($argument);
}
}
""",
"Match.php": """<?php
namespace App;
class MatchDemo {
public function run($mode, $object): string {
return (string) match ($mode) {
SomeEnum::First => $object->first(),
SomeEnum::Second => $object->second(),
};
}
}
""",
}
paths = [_write(tmp_path, name, body) for name, body in files.items()]
result = extract(paths, cache_root=Path(tempfile.mkdtemp()), root=tmp_path, parallel=False)
labels = _labels(result)
assert "VoidDemo" in labels
assert ".go()" in labels
assert "MatchDemo" in labels
assert ".run()" in labels
assert (".go()", "SomeClass") in _calls(result)
Loading