diff --git a/graphify/extractors/php_normalize.py b/graphify/extractors/php_normalize.py new file mode 100644 index 0000000000..6bf437c06b --- /dev/null +++ b/graphify/extractors/php_normalize.py @@ -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 diff --git a/tests/test_php_85_void_cast_match.py b/tests/test_php_85_void_cast_match.py new file mode 100644 index 0000000000..ca71af0ee4 --- /dev/null +++ b/tests/test_php_85_void_cast_match.py @@ -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", """ $object->first() }` keeps the arms.""" + path = _write(tmp_path, "Mode.php", """ $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", """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", """ $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)