Index JavaScript in inline script blocks of a PHP file

The PHP tree sitter grammar treats a span that is not PHP, such as an
inline script block, as opaque markup, so any JavaScript declared
there was entirely absent from the graph: functions, calls, and
everything else in that region.

extract_php now runs a second pass after the PHP extraction. Every
part of the source that is not a script body is blanked to spaces,
preserving newlines so line numbers stay accurate, and the masked
source is parsed with the JS grammar the same way the Vue extractor
already masks everything outside its own script block. The two node
sets are merged onto the one file node, so a symbol declared in the
script block looks like any other symbol in the file. A script tag
with only a src attribute and no body masks to an empty region and
contributes nothing, and a file with no script tag at all skips the
second pass entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayushcodes10andClaude Sonnet 5 committed 2026-09-17 15:34:49 +05:30
1 parent 26b02b5e34
commit 2114766c91
1 file changed
+65 -2
+65 -2
View File
@@ -2546,9 +2546,72 @@ def extract_scala(path: Path) -> dict:
return _extract_generic(path, _SCALA_CONFIG)
_PHP_SCRIPT_RE = re.compile(
r"<script\b(?:\"[^\"]*\"|'[^']*'|[^>\"'])*>([\s\S]*?)</script\s*>",
re.IGNORECASE,
)
def _php_mask_to_script_blocks(src: str) -> tuple[str, bool]:
"""Blank everything outside inline ``<script>`` bodies, keeping line numbers.
tree-sitter-php treats non-PHP spans (including inline ``<script>`` blocks)
as opaque text, so JS declared there is invisible to the PHP grammar (#2320).
Mirrors :func:`_vue_mask_non_script`'s approach: replace every character
outside a script body with a space (preserving ``\\r``/``\\n`` so source
locations still line up), leaving only the script content for a JS parse.
A ``<script src="…"></script>`` with no body masks to an empty region,
which parses as empty JS and contributes nothing — no special-casing needed.
Returns ``(masked_source, had_script)``; the caller skips the JS pass
entirely when ``had_script`` is False.
"""
def _blank(s: str) -> str:
return re.sub(r"[^\r\n]", " ", s)
out: list[str] = []
pos = 0
had_script = False
for m in _PHP_SCRIPT_RE.finditer(src):
had_script = True
out.append(_blank(src[pos:m.start(1)])) # everything up to and including <script ...>
out.append(m.group(1)) # script body, verbatim
out.append(_blank(src[m.end(1):m.end()])) # </script> and any trailing part of the match
pos = m.end()
if not had_script:
return src, False
out.append(_blank(src[pos:]))
return "".join(out), True
def extract_php(path: Path) -> dict:
"""Extract classes, functions, methods, namespace uses, and calls from a .php file."""
return _extract_generic(path, _PHP_CONFIG)
"""Extract classes, functions, methods, namespace uses, and calls from a .php file.
Also parses any inline ``<script>`` blocks as JavaScript (#2320): the PHP
grammar sees those spans as opaque markup, so a page mixing PHP with
client-side JS previously indexed only its PHP half. The JS pass masks
everything else to blanks (preserving line numbers) and reuses the same
file node id, so JS symbols land under the one file node like any other
PHP symbol; a same-name PHP/JS collision (rare — different languages,
same file) is resolved by keeping the PHP node, since PHP is the file's
primary language.
"""
result = _extract_generic(path, _PHP_CONFIG)
try:
src = path.read_text(encoding="utf-8", errors="replace")
masked, had_script = _php_mask_to_script_blocks(src)
if not had_script:
return result
js_result = _extract_generic(path, _JS_CONFIG, source_override=masked.encode("utf-8"))
existing_ids = {n["id"] for n in result.get("nodes", [])}
for n in js_result.get("nodes", []):
if n["id"] not in existing_ids:
result.setdefault("nodes", []).append(n)
existing_ids.add(n["id"])
for e in js_result.get("edges", []):
result.setdefault("edges", []).append(e)
except Exception:
pass
return result
# One level of balanced parens (e.g. `Foo #(Bar #(int))`) — bounded so malformed