diff --git a/graphify/extract.py b/graphify/extract.py index 0d3bbb2b7..cb01dc530 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/tests/test_php_unparsed_casts.py b/tests/test_php_unparsed_casts.py new file mode 100644 index 000000000..a6721ca6e --- /dev/null +++ b/tests/test_php_unparsed_casts.py @@ -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 = ( + "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": ( + " $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"\n

(void) html

\n" + ) + assert _php_blank_unparsed_casts(src) == src + + +def test_blanking_keeps_offsets_and_line_numbers(): + src = b" 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("