mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 16:09:26 +00:00
fix(extract): parse only Svelte script blocks in the AST pass
extract_svelte fed the whole .svelte file to the JS grammar. The template
is not JS, so every component reported a syntax error at line 1 and no
symbol declared inside <script> was ever reached — only imports survived,
via the regex rescue pass.
Blank everything outside the <script> bodies (keeping newlines so
locations still match) and parse with the grammar the first block's lang
implies: js/jsx -> JS, ts or unset -> TS, a superset of JS and Svelte's
own default. <script module> and <script context="module"> are parsed
alongside the instance block. Scripts with a non-JS type
(application/ld+json, ...) are blanked too, and a script region is
terminated in place of the following `<` so two blocks on one line do not
run together. The existing regex import rescue is unchanged; an import
both passes resolve yields one edge after build.dedupe_edges, as on the
Astro path.
The masker mirrors _astro_mask_non_script and shares the script-tag and
non-JS-type patterns with it instead of duplicating them.
Fixes #3928. Covers the Svelte half of #3942 (markup is blanked, so a
template {#if} no longer mints a spurious if() node).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b64806924e)
This commit is contained in:
1 parent
8629f02ff0
commit
f9b42e8d23
2 files changed
+250
-20
No files matched your search
+77
-20
@@ -154,6 +154,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
|
||||
_ts_collect_type_refs,
|
||||
_ts_heritage_clause_entries,
|
||||
_ts_walk_class_members,
|
||||
_VUE_SCRIPT_LANG_RE as _SCRIPT_LANG_RE,
|
||||
_vue_mask_non_script,
|
||||
_walk_js_tree,
|
||||
_walk_python_tree,
|
||||
@@ -2380,17 +2381,79 @@ def _emit_rescued_import(
|
||||
existing_ids.add(node_id)
|
||||
|
||||
|
||||
def extract_svelte(path: Path) -> dict:
|
||||
"""Extract imports from .svelte files: script-block via JS AST + template regex fallback.
|
||||
_HTML_SCRIPT_TAG_RE = re.compile(
|
||||
r"<script\b((?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>([\s\S]*?)</script\s*>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NON_JS_SCRIPT_TYPE_RE = re.compile(
|
||||
r"""\btype\s*=\s*["']?(?!module\b|text/javascript\b|application/javascript\b)""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
Tree-sitter only sees the <script> block. Svelte template syntax like
|
||||
{#await import('./X.svelte')} lives in the markup layer and is invisible
|
||||
to the JS parser, so a regex pass covers those dynamic imports.
|
||||
|
||||
def _svelte_mask_non_script(src: str) -> tuple[str, str | None]:
|
||||
"""Blank everything in a ``.svelte`` file except JS ``<script>`` bodies.
|
||||
|
||||
Every character outside those bodies becomes a space (``\\r``/``\\n`` are
|
||||
kept), so AST locations match the original file. Scripts with a non-JS
|
||||
``type`` (``application/ld+json`` and the like) are blanked too: their
|
||||
bodies are not statements and would only add parse errors. Returns
|
||||
``(masked_source, lang)``; ``lang`` is the first JS block's declared
|
||||
``lang``. Mirrors :func:`_astro_mask_non_script`.
|
||||
"""
|
||||
result = _extract_generic(path, _JS_CONFIG)
|
||||
chars = [c if c in "\r\n" else " " for c in src]
|
||||
lang: str | None = None
|
||||
for m in _HTML_SCRIPT_TAG_RE.finditer(src):
|
||||
if _NON_JS_SCRIPT_TYPE_RE.search(m.group(1)):
|
||||
continue
|
||||
start, end = m.start(2), m.end(2)
|
||||
chars[start:end] = src[start:end]
|
||||
# Terminate the region in place of the following `<`, so two scripts on
|
||||
# one line don't run together into a single statement.
|
||||
if end < len(chars) and chars[end] == " ":
|
||||
chars[end] = ";"
|
||||
if lang is None:
|
||||
lang_m = _SCRIPT_LANG_RE.search(m.group(1))
|
||||
if lang_m:
|
||||
lang = lang_m.group(1).lower()
|
||||
return "".join(chars), lang
|
||||
|
||||
|
||||
def extract_svelte(path: Path) -> dict:
|
||||
"""Extract imports and symbols from .svelte files: script-block AST + template regex fallback.
|
||||
|
||||
The AST pass parses only the ``<script>`` bodies, blanking the markup and
|
||||
style regions so line numbers still line up — the same masking
|
||||
:func:`extract_vue` and :func:`extract_astro` use. Feeding the whole file to
|
||||
the JS grammar made the template a top-level ERROR at line 1, so every
|
||||
``.svelte`` file was flagged as a syntax error and no symbol inside
|
||||
``<script>`` (functions, classes, runes) was ever reached; only imports
|
||||
survived, via the regex rescue below (#3928).
|
||||
|
||||
The grammar follows the first block's declared ``lang`` (``js``/``jsx``→JS,
|
||||
``ts`` or unset→TS, a superset of JS), matching Svelte's own default. Both
|
||||
``<script>`` and ``<script module>`` / ``<script context="module">`` blocks
|
||||
are parsed. A ``<script>`` carrying a non-JS ``type`` (``application/ld+json``
|
||||
and the like) is masked out rather than parsed as code.
|
||||
|
||||
Svelte template syntax like {#await import('./X.svelte')} lives in the markup
|
||||
layer and is invisible to the JS parser, so a regex pass covers those
|
||||
dynamic imports.
|
||||
"""
|
||||
try:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
masked, lang = _svelte_mask_non_script(src)
|
||||
config = _JS_CONFIG if lang in ("js", "jsx") else _TS_CONFIG
|
||||
masked_bytes = masked.encode("utf-8")
|
||||
if config is _TS_CONFIG:
|
||||
masked_bytes = _normalize_ts_import_types(masked_bytes) or masked_bytes
|
||||
|
||||
result = _extract_generic(path, config, source_override=masked_bytes)
|
||||
try:
|
||||
import re as _re
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
existing_ids = {n["id"] for n in result.get("nodes", [])}
|
||||
# Source file node ID must match the one _extract_generic creates:
|
||||
# _make_id(str(path)) - single arg, no stem prefix. Otherwise the source
|
||||
@@ -2410,11 +2473,11 @@ def extract_svelte(path: Path) -> dict:
|
||||
result, existing_ids, file_node_id, path, raw,
|
||||
"dynamic_import", aliases, base_url,
|
||||
)
|
||||
# Static imports inside <script> blocks. The JS tree-sitter parser fed
|
||||
# the full .svelte file produces a top-level ERROR node (HTML markup
|
||||
# is not valid JS), so import_statement nodes are never reached and
|
||||
# static imports are silently dropped (#713). Regex over each script
|
||||
# body recovers them.
|
||||
# Static imports inside <script> blocks (#713). The masked AST pass now
|
||||
# reaches these import_statement nodes itself, so this is a fallback for
|
||||
# a script body the grammar still cannot parse. An import both passes
|
||||
# resolve yields the same (source, target, relation) edge twice, which
|
||||
# build.dedupe_edges collapses — same as the Astro path (#3902).
|
||||
script_re = _re.compile(
|
||||
r"<script\b[^>]*>([\s\S]*?)</script\s*>", _re.IGNORECASE
|
||||
)
|
||||
@@ -2437,14 +2500,8 @@ def extract_svelte(path: Path) -> dict:
|
||||
|
||||
|
||||
_ASTRO_FRONTMATTER_RE = re.compile(r"\A\s*---[^\S\r\n]*\r?\n([\s\S]*?)\r?\n---")
|
||||
_ASTRO_SCRIPT_RE = re.compile(
|
||||
r"<script\b((?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>([\s\S]*?)</script\s*>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ASTRO_NON_JS_TYPE_RE = re.compile(
|
||||
r"""\btype\s*=\s*["']?(?!module\b|text/javascript\b|application/javascript\b)""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ASTRO_SCRIPT_RE = _HTML_SCRIPT_TAG_RE
|
||||
_ASTRO_NON_JS_TYPE_RE = _NON_JS_SCRIPT_TYPE_RE
|
||||
|
||||
|
||||
def _astro_mask_non_script(src: str) -> str:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for `.svelte` extraction (#3928).
|
||||
|
||||
A `.svelte` file is markup with one or two `<script>` blocks. Tree-sitter fed
|
||||
the whole file produces a top-level ERROR node at line 1 because the template is
|
||||
not JS, so the AST pass never reached the `function_declaration` nodes inside
|
||||
`<script>` — every component was reported as a syntax error and only its
|
||||
imports survived, via the regex rescue pass. :func:`extract_svelte` masks the
|
||||
markup and style regions and parses just the script bodies, the same strategy
|
||||
:func:`extract_vue` and :func:`extract_astro` use.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extract import _make_id, extract_svelte
|
||||
|
||||
|
||||
def _write(path: Path, body: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _labels(result: dict) -> dict[str, str]:
|
||||
"""Label -> source_location for every node except the file node."""
|
||||
file_id = result.get("nodes", [{}])[0].get("id")
|
||||
return {
|
||||
str(n.get("label")): str(n.get("source_location"))
|
||||
for n in result.get("nodes", [])
|
||||
if n.get("id") != file_id
|
||||
}
|
||||
|
||||
|
||||
def _import_targets(result: dict, *, relation: str | None = None) -> set[str]:
|
||||
return {
|
||||
str(e.get("target") or "")
|
||||
for e in result.get("edges", [])
|
||||
if relation is None or e.get("relation") == relation
|
||||
}
|
||||
|
||||
|
||||
def test_extract_svelte_template_is_not_a_parse_error(tmp_path):
|
||||
"""The template is not JS; only `<script>` bodies are parsed (#3928).
|
||||
|
||||
Parsing the whole file reported every component as a syntax error at line 1
|
||||
and dropped every symbol declared inside `<script>`.
|
||||
"""
|
||||
component = _write(
|
||||
tmp_path / "src/A.svelte",
|
||||
"""<script>
|
||||
import { foo } from "./x";
|
||||
let n = 1;
|
||||
function bump() { n++; }
|
||||
</script>
|
||||
<button onclick={bump}>{n}</button>
|
||||
""",
|
||||
)
|
||||
result = extract_svelte(component)
|
||||
assert result.get("parse_errors") is None
|
||||
labels = _labels(result)
|
||||
assert "bump()" in labels
|
||||
# Masking keeps offsets, so locations still point at the original lines.
|
||||
assert labels["bump()"] == "L4"
|
||||
|
||||
|
||||
def test_extract_svelte_parses_module_and_instance_scripts(tmp_path):
|
||||
"""Svelte 5 `<script module>` and the instance block are both code."""
|
||||
component = _write(
|
||||
tmp_path / "src/Both.svelte",
|
||||
"""<script module>
|
||||
export function shared() { return 1; }
|
||||
</script>
|
||||
<script>
|
||||
function local() { return 2; }
|
||||
</script>
|
||||
<p>hi</p>
|
||||
""",
|
||||
)
|
||||
result = extract_svelte(component)
|
||||
assert result.get("parse_errors") is None
|
||||
labels = _labels(result)
|
||||
assert "shared()" in labels and "local()" in labels
|
||||
assert labels["shared()"] == "L2"
|
||||
assert labels["local()"] == "L5"
|
||||
|
||||
|
||||
def test_extract_svelte_lang_ts_parses_type_syntax(tmp_path):
|
||||
"""`<script lang="ts">` must be parsed with the TS grammar, not JS."""
|
||||
component = _write(
|
||||
tmp_path / "src/Typed.svelte",
|
||||
"""<script lang="ts">
|
||||
interface Props { year: number }
|
||||
let n = $state(1);
|
||||
function bump(): void { n++; }
|
||||
</script>
|
||||
<button onclick={bump}>{n}</button>
|
||||
""",
|
||||
)
|
||||
result = extract_svelte(component)
|
||||
assert result.get("parse_errors") is None
|
||||
labels = _labels(result)
|
||||
assert "Props" in labels
|
||||
assert "bump()" in labels
|
||||
|
||||
|
||||
def test_extract_svelte_non_js_script_type_is_not_parsed_as_code(tmp_path):
|
||||
"""`<script type="application/ld+json">` is data, not statements."""
|
||||
component = _write(
|
||||
tmp_path / "src/Ld.svelte",
|
||||
"""<script type="application/ld+json">{ "@type": "Event", "name": "x" }</script>
|
||||
<script>
|
||||
function go() { return 1; }
|
||||
</script>
|
||||
<p>hi</p>
|
||||
""",
|
||||
)
|
||||
result = extract_svelte(component)
|
||||
assert result.get("parse_errors") is None
|
||||
assert "go()" in _labels(result)
|
||||
|
||||
|
||||
def test_extract_svelte_static_import_still_resolves(tmp_path):
|
||||
"""Masking must not cost the imports the regex rescue already recovered."""
|
||||
component = _write(
|
||||
tmp_path / "src/Imports.svelte",
|
||||
"""<script>
|
||||
import { helper } from "./helper";
|
||||
</script>
|
||||
<p>hi</p>
|
||||
""",
|
||||
)
|
||||
helper = _write(tmp_path / "src/helper.ts", "export function helper(){}\n")
|
||||
|
||||
result = extract_svelte(component)
|
||||
assert _make_id(str(helper)) in _import_targets(result, relation="imports_from")
|
||||
|
||||
|
||||
def test_extract_svelte_dynamic_import_in_template(tmp_path):
|
||||
"""`{#await import('./X.svelte')}` lives in markup, so the regex pass owns it."""
|
||||
component = _write(
|
||||
tmp_path / "src/Lazy.svelte",
|
||||
"""<script>
|
||||
let show = true;
|
||||
</script>
|
||||
{#await import('./Other.svelte') then Mod}
|
||||
<Mod.default />
|
||||
{/await}
|
||||
""",
|
||||
)
|
||||
other = _write(tmp_path / "src/Other.svelte", "<p>o</p>\n")
|
||||
|
||||
result = extract_svelte(component)
|
||||
assert _make_id(str(other)) in _import_targets(result, relation="dynamic_import")
|
||||
|
||||
|
||||
def test_extract_svelte_scripts_on_one_line_do_not_merge(tmp_path):
|
||||
"""Two script bodies on one line must not parse as a single statement."""
|
||||
component = _write(
|
||||
tmp_path / "src/Inline.svelte",
|
||||
"<script>const a = 1</script><script>function b() {}</script>\n",
|
||||
)
|
||||
result = extract_svelte(component)
|
||||
assert result.get("parse_errors") is None
|
||||
assert "b()" in _labels(result)
|
||||
|
||||
|
||||
def test_extract_svelte_markup_only_component_does_not_crash(tmp_path):
|
||||
"""A `.svelte` file need not have a `<script>` block at all."""
|
||||
component = _write(tmp_path / "src/Plain.svelte", "<h1>no script here</h1>\n")
|
||||
result = extract_svelte(component)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("parse_errors") is None
|
||||
assert _import_targets(result, relation="imports_from") == set()
|
||||
Reference in new issue
Block a user