Merge pull request #2056 from bmcnaboe/fix/svelte-html-astro-embedded-structure

fix(extract): extract structure from Svelte/HTML/Astro inline scripts
This commit is contained in:
Martin Vogel
2026-09-07 00:10:22 +02:00
committed by GitHub
5 changed files with 291 additions and 84 deletions
+80 -66
View File
@@ -1383,12 +1383,13 @@ static void parse_spec_imports(CBMExtractCtx *ctx) {
ts_tree_cursor_delete(&cursor);
}
// --- Embedded-language imports ---
// Generic walker for host grammars (Svelte, Vue, HTML, Astro, ...) whose AST
// keeps embedded sub-language source as raw_text (or similar) without parsing
// it. The host's CBMLangSpec.embedded_imports declares which content nodes
// hold which sub-language; we re-parse each match with the embedded grammar
// and run the standard ES import walker over the inner AST.
// --- Embedded-language structure and imports ---
// Generic walker for host grammars (Svelte, Vue, HTML, Astro, CFML, ...) whose
// AST keeps embedded sub-language source as raw_text (or similar) without
// parsing it. The host's CBMLangSpec.embedded_imports declares which content
// nodes hold which sub-language; we re-parse each match with the embedded
// grammar and run the definition, import and call extractors over the inner
// AST, so a component's script contributes symbols like a standalone file.
//
// No grammar symbols are referenced here — the embedded TSLanguage is
// resolved through cbm_ts_language(spec->embedded_language), the same hook
@@ -1458,66 +1459,94 @@ static bool ascii_trimmed_equals(const char *value, const char *expected) {
return true;
}
/* Vue is the only host that opts into structural embedded extraction. Restrict
* its inline programs to the language forms whose parsers we can select
* exactly. A src attribute always denotes an external program and therefore
* suppresses any inline extraction, even for malformed mixed markup. */
static bool vue_embedded_language(CBMExtractCtx *ctx, TSNode script, CBMLanguage *language) {
enum { VUE_ATTR_STACK_CAP = 128 };
TSNode stack[VUE_ATTR_STACK_CAP];
/* The text of a <script> attribute's value, or "" when it carries none
* (`<script type>`), so every attribute reads through one path. */
static const char *script_attribute_value(CBMExtractCtx *ctx, TSNode attribute) {
TSNode value_node = attribute;
if (!find_first_descendant_of(attribute, "attribute_value", &value_node)) {
return "";
}
return cbm_node_text(ctx->arena, value_node, ctx->source);
}
/* Apply one <script> attribute to the parse decision. Returns false when the
* attribute rules the block out of inline extraction:
* src= always names an external program, so nothing inline is extracted,
* even for malformed mixed markup;
* lang= selects the JavaScript or TypeScript grammar; any other language
* has no parser here;
* type= plain HTML routinely carries non-program blocks (application/json,
* importmap, text/x-template, application/ld+json) that would parse
* into garbage definitions and calls. Only the JavaScript forms in
* current use pass — absent or empty, module, text/javascript,
* application/javascript — and the rest bail. */
static bool apply_script_attribute(CBMExtractCtx *ctx, TSNode attribute, CBMLanguage *language) {
if (ts_node_named_child_count(attribute) == 0) {
return false;
}
const char *name = cbm_node_text(ctx->arena, ts_node_named_child(attribute, 0), ctx->source);
if (ascii_trimmed_equals(name, "src")) {
return false;
}
if (ascii_trimmed_equals(name, "lang")) {
const char *value = script_attribute_value(ctx, attribute);
if (ascii_trimmed_equals(value, "js") || ascii_trimmed_equals(value, "javascript")) {
*language = CBM_LANG_JAVASCRIPT;
return true;
}
if (ascii_trimmed_equals(value, "ts") || ascii_trimmed_equals(value, "typescript")) {
*language = CBM_LANG_TYPESCRIPT;
return true;
}
return false;
}
if (ascii_trimmed_equals(name, "type")) {
const char *value = script_attribute_value(ctx, attribute);
return ascii_trimmed_equals(value, "") || ascii_trimmed_equals(value, "module") ||
ascii_trimmed_equals(value, "text/javascript") ||
ascii_trimmed_equals(value, "application/javascript");
}
return true;
}
/* Markup hosts (Vue, Svelte, HTML, Astro) hang the attributes that decide
* whether and how an inline program is parsed off the <script> start tag.
* *language enters holding the spec row's default and leaves holding the
* grammar to parse with; an attribute-free block (Astro's frontmatter fence)
* keeps the row's language rather than falling through to JavaScript. */
static bool script_embedded_language(CBMExtractCtx *ctx, TSNode script, CBMLanguage *language) {
enum { SCRIPT_ATTR_STACK_CAP = 128 };
TSNode stack[SCRIPT_ATTR_STACK_CAP];
int top = 0;
bool has_lang = false;
bool lang_supported = true;
CBMLanguage selected = CBM_LANG_JAVASCRIPT;
stack[top++] = script;
while (top > 0) {
TSNode node = stack[--top];
if (strcmp(ts_node_type(node), "attribute") == 0) {
uint32_t named_count = ts_node_named_child_count(node);
if (named_count == 0) {
if (!apply_script_attribute(ctx, node, language)) {
return false;
}
TSNode name_node = ts_node_named_child(node, 0);
char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
if (ascii_trimmed_equals(name, "src")) {
return false;
}
if (ascii_trimmed_equals(name, "lang")) {
TSNode value_node = node;
if (!find_first_descendant_of(node, "attribute_value", &value_node)) {
return false;
}
char *value = cbm_node_text(ctx->arena, value_node, ctx->source);
has_lang = true;
if (ascii_trimmed_equals(value, "js") ||
ascii_trimmed_equals(value, "javascript")) {
selected = CBM_LANG_JAVASCRIPT;
} else if (ascii_trimmed_equals(value, "ts") ||
ascii_trimmed_equals(value, "typescript")) {
selected = CBM_LANG_TYPESCRIPT;
} else {
lang_supported = false;
}
}
continue;
}
uint32_t count = ts_node_named_child_count(node);
if ((int)count > VUE_ATTR_STACK_CAP - top) {
if ((int)count > SCRIPT_ATTR_STACK_CAP - top) {
return false;
}
for (int i = (int)count - 1; i >= 0; i--) {
stack[top++] = ts_node_named_child(node, (uint32_t)i);
}
}
if (has_lang && !lang_supported) {
return false;
}
*language = selected;
return true;
}
/* CFML's <cfscript> carries no attributes and its embedded language is fixed
* by the spec row; only the markup hosts consult the <script> tag. */
static bool markup_script_host(CBMLanguage language) {
return language == CBM_LANG_VUE || language == CBM_LANG_SVELTE || language == CBM_LANG_HTML ||
language == CBM_LANG_ASTRO;
}
static void parse_one_embedded_block(CBMExtractCtx *ctx, const CBMEmbeddedBlock *block,
CBMLanguage embedded_language, bool extract_structure) {
CBMLanguage embedded_language) {
const TSLanguage *language = cbm_ts_language(embedded_language);
if (!language || ctx->source_len < 0) {
return;
@@ -1564,13 +1593,9 @@ static void parse_one_embedded_block(CBMExtractCtx *ctx, const CBMEmbeddedBlock
.macro_table = ctx->macro_table,
.return_type_table = ctx->return_type_table,
};
if (extract_structure) {
cbm_extract_definitions_without_module(&sub_ctx);
}
cbm_extract_definitions_without_module(&sub_ctx);
walk_es_imports(&sub_ctx, sub_ctx.root);
if (extract_structure) {
cbm_extract_unified(&sub_ctx);
}
cbm_extract_unified(&sub_ctx);
ts_tree_delete(tree);
ts_parser_delete(parser);
@@ -1588,22 +1613,11 @@ static void parse_embedded_imports(CBMExtractCtx *ctx) {
embedded_collect_content_nodes(ctx->root, e, hits, &hit_count, MAX_EMBEDDED_BLOCKS);
for (int i = 0; i < hit_count; i++) {
CBMLanguage embedded_language = e->embedded_language;
/* Structure (defs + calls), not just imports, for hosts whose
* embedded language carries real code. Vue since #1852; CFML's
* <cfscript> since the #1412 distillation. */
bool extract_structure =
ctx->language == CBM_LANG_VUE || ctx->language == CBM_LANG_CFML;
/* The attribute resolver is Vue's: it inspects <script lang=/src=>
* and OVERRIDES the spec's embedded language (JS default, TS on
* lang="ts", bail on src=). CFML's cf_script_tag carries no such
* attributes and its embedded language is fixed by the spec row
* (CFSCRIPT) — running the resolver would silently rewrite it to
* JavaScript. Resolve only for Vue. */
if (ctx->language == CBM_LANG_VUE &&
!vue_embedded_language(ctx, hits[i].script, &embedded_language)) {
if (markup_script_host(ctx->language) &&
!script_embedded_language(ctx, hits[i].script, &embedded_language)) {
continue;
}
parse_one_embedded_block(ctx, &hits[i], embedded_language, extract_structure);
parse_one_embedded_block(ctx, &hits[i], embedded_language);
}
}
}
+9 -6
View File
@@ -890,8 +890,9 @@ static const char *graphql_field_types[] = {"field_definition", "input_value_def
// ==================== Embedded sub-languages ====================
// Host grammars (Svelte/Vue/HTML/Astro) treat <script> bodies as raw_text and
// do not recurse into them. Declaring the host's script-content node here lets
// the generic embedded-imports walker re-parse that slice with the JS grammar
// so the existing ES import extractor sees real import_statement nodes.
// the generic embedded walker re-parse that slice with the declared grammar so
// its definitions, imports and calls extract in host-file coordinates. The
// declared language is the default; a <script lang=> attribute overrides it.
// Terminator: an entry whose script_node_type is NULL.
static const CBMEmbeddedLangSpec cfml_embedded_imports[] = {
/* Tag-dialect CFML keeps <cfscript> bodies as opaque cf_script_content;
@@ -915,10 +916,12 @@ static const CBMEmbeddedLangSpec html_embedded_imports[] = {
};
static const CBMEmbeddedLangSpec astro_embedded_imports[] = {
/* Astro component scripts live in the `---` frontmatter fence, which the
* grammar keeps as an unparsed frontmatter_js_block. Re-parse that slice
* with the JS grammar so `import X from './X.astro'` becomes a real edge. */
{"frontmatter", "frontmatter_js_block", CBM_LANG_JAVASCRIPT},
{"script_element", "raw_text", CBM_LANG_JAVASCRIPT},
* grammar keeps as an unparsed frontmatter_js_block. Astro type-checks the
* fence and its <script> bodies as TypeScript without any lang= attribute
* saying so (the fence cannot carry one), so TypeScript is the deliberate
* default for both; it parses untyped JavaScript unchanged. */
{"frontmatter", "frontmatter_js_block", CBM_LANG_TYPESCRIPT},
{"script_element", "raw_text", CBM_LANG_TYPESCRIPT},
{NULL, NULL, 0},
};
+14
View File
@@ -420,6 +420,19 @@ TEST(es_calls_vue_embedded_issue1410) {
PASS();
}
TEST(es_calls_svelte_embedded_issue1807) {
static const ES_LangFile f[] = {
{"Toggle.svelte",
"<script lang=\"ts\">\n"
"function callee(): number { return 42; }\n"
"function caller(): number { return callee(); }\n"
"</script>\n"
"<button on:click={caller}>Toggle</button>\n"},
};
ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "caller", "callee"), 1);
PASS();
}
/* Java: caller in Main.java calls static method from Util.java (same package). */
TEST(es_calls_crossfile_java) {
static const ES_LangFile f[] = {
@@ -927,6 +940,7 @@ SUITE(edge_structural) {
RUN_TEST(es_calls_crossfile_kotlin);
RUN_TEST(es_calls_crossfile_csharp);
RUN_TEST(es_calls_vue_embedded_issue1410);
RUN_TEST(es_calls_svelte_embedded_issue1807);
/* ── FAMILY 2: INHERITS cross-file ────────────────────────── */
/* GREEN: Java, C#, C++ (extraction confirmed correct). */
+184 -10
View File
@@ -3372,7 +3372,10 @@ TEST(vue_embedded_structure_negative_controls_issue1410) {
PASS();
}
TEST(vue_embedded_structure_host_controls_issue1410) {
/* The sibling hosts ride Vue's embedded seam: the function and call a .ts file
* yields come out of a plain <script> (or Astro's frontmatter fence) the same
* way, alongside the import those blocks always produced. */
TEST(embedded_structure_sibling_hosts_issue1807) {
CBMFileResult *plain =
extract("function plainTs(): void { target(); }\n", CBM_LANG_TYPESCRIPT, "t", "plain.ts");
ASSERT_NOT_NULL(plain);
@@ -3386,26 +3389,193 @@ TEST(vue_embedded_structure_host_controls_issue1410) {
const char *path;
const char *source;
} hosts[] = {
{CBM_LANG_SVELTE, "Control.svelte",
"<script>import value from './svelte.js'; function hidden() { target(); }</script>\n"},
{CBM_LANG_HTML, "control.html",
"<script>import value from './html.js'; function hidden() { target(); }</script>\n"},
{CBM_LANG_ASTRO, "Control.astro",
"---\nimport value from './astro.js'; function hidden() { target(); }\n---\n"},
{CBM_LANG_SVELTE, "Sibling.svelte",
"<script>import value from './svelte.js'; function visible() { target(); }</script>\n"},
{CBM_LANG_HTML, "sibling.html",
"<script>import value from './html.js'; function visible() { target(); }</script>\n"},
{CBM_LANG_ASTRO, "Sibling.astro",
"---\nimport value from './astro.js'; function visible() { target(); }\n---\n"},
};
for (int i = 0; i < 3; i++) {
CBMFileResult *r = extract(hosts[i].source, hosts[i].language, "t", hosts[i].path);
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(count_defs_with_label(r, "Module"), 1);
ASSERT_EQ(count_defs_with_label(r, "Function"), 0);
ASSERT_EQ(r->calls.count, 0);
ASSERT_EQ(count_defs_named(r, "Function", "visible"), 1);
ASSERT_EQ(count_calls_named(r, "target"), 1);
ASSERT_EQ(r->imports.count, 1);
cbm_free_result(r);
}
PASS();
}
/* Blocks that must never yield inline symbols, whatever the host: an external
* program (src=) and a non-JavaScript MIME type. The bodies are deliberately
* code, so a leak would surface as a definition and a call. HTML's own tag
* walker still records the src= reference as an import; that edge names the
* external file and is not the inline body leaking through. */
TEST(embedded_structure_inert_blocks_issue1807) {
static const struct {
CBMLanguage language;
const char *path;
const char *source;
int imports;
} blocks[] = {
{CBM_LANG_VUE, "Inert.vue",
"<script type=\"application/json\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_VUE, "Inert.vue",
"<script src=\"./x.js\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_SVELTE, "Inert.svelte",
"<script type=\"application/json\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_SVELTE, "Inert.svelte",
"<script src=\"./x.js\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_HTML, "inert.html",
"<script type=\"application/json\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_HTML, "inert.html",
"<script type=\"importmap\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_HTML, "inert.html",
"<script type=\"text/x-template\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_HTML, "inert.html",
"<script src=\"./x.js\">function hidden() { target(); }</script>\n", 1},
{CBM_LANG_ASTRO, "Inert.astro",
"<script type=\"application/json\">function hidden() { target(); }</script>\n", 0},
{CBM_LANG_ASTRO, "Inert.astro",
"<script src=\"./x.js\">function hidden() { target(); }</script>\n", 0},
};
for (int i = 0; i < 10; i++) {
CBMFileResult *r = extract(blocks[i].source, blocks[i].language, "t", blocks[i].path);
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(count_defs_with_label(r, "Module"), 1);
ASSERT_EQ(count_defs_with_label(r, "Function"), 0);
ASSERT_EQ(r->calls.count, 0);
ASSERT_EQ(r->imports.count, blocks[i].imports);
cbm_free_result(r);
}
PASS();
}
/* Svelte's module-level block (<script context="module">, or <script module>
* since Svelte 5) is a second block in the same file: both contribute, each
* honouring its own lang=, in host-file coordinates. */
TEST(svelte_embedded_structure_both_blocks_issue1807) {
static const char *sources[] = {
"<script context=\"module\" lang=\"ts\">\n"
"export function fromModule(): number { return shared(); }\n"
"</script>\n"
"<script lang=\"ts\">\n"
"import { shared } from './shared';\n"
"function fromInstance(): number { return shared() + fromModule(); }\n"
"</script>\n"
"<button on:click={fromInstance}>{fromModule()}</button>\n",
"<script module lang=\"ts\">\n"
"export function fromModule(): number { return shared(); }\n"
"</script>\n"
"<script lang=\"ts\">\n"
"import { shared } from './shared';\n"
"function fromInstance(): number { return shared() + fromModule(); }\n"
"</script>\n"
"<button onclick={fromInstance}>{fromModule()}</button>\n",
};
for (int i = 0; i < 2; i++) {
CBMFileResult *r = extract(sources[i], CBM_LANG_SVELTE, "t", "Widget.svelte");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(count_defs_with_label(r, "Function"), 2);
ASSERT_EQ(count_defs_named(r, "Function", "fromModule"), 1);
ASSERT_EQ(count_defs_named(r, "Function", "fromInstance"), 1);
ASSERT_EQ(count_calls_named(r, "shared"), 2);
ASSERT_EQ(count_calls_named(r, "fromModule"), 1);
ASSERT_EQ(r->imports.count, 1);
ASSERT(has_import(r, "shared"));
for (int d = 0; d < r->defs.count; d++) {
const CBMDefinition *def = &r->defs.items[d];
if (strcmp(def->name, "fromModule") == 0) {
ASSERT_EQ(def->start_line, 2);
} else if (strcmp(def->name, "fromInstance") == 0) {
ASSERT_EQ(def->start_line, 6);
}
}
cbm_free_result(r);
}
PASS();
}
/* Every type= form HTML itself runs as JavaScript contributes, in host-file
* coordinates; a data block on the same page does not. */
TEST(html_embedded_structure_issue1807) {
CBMFileResult *r =
extract("<!DOCTYPE html><html><head>\n"
"<script type=\"module\">\n"
"import { renderApp } from './app.js';\n"
"function boot() { renderApp(); }\n"
"</script>\n"
"<script type=\"text/javascript\">\n"
"function legacy() { boot(); }\n"
"</script>\n"
"<script type=\"application/javascript\">\n"
"function fallback() { legacy(); }\n"
"</script>\n"
"<script type=\"application/ld+json\">{\"@type\": \"Thing\"}</script>\n"
"</head><body></body></html>\n",
CBM_LANG_HTML, "t", "index.html");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(count_defs_with_label(r, "Function"), 3);
ASSERT_EQ(count_calls_named(r, "renderApp"), 1);
ASSERT_EQ(count_calls_named(r, "boot"), 1);
ASSERT_EQ(count_calls_named(r, "legacy"), 1);
ASSERT_EQ(r->imports.count, 1);
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (strcmp(d->name, "boot") == 0) {
ASSERT_EQ(d->start_line, 4);
} else if (strcmp(d->name, "legacy") == 0) {
ASSERT_EQ(d->start_line, 7);
} else if (strcmp(d->name, "fallback") == 0) {
ASSERT_EQ(d->start_line, 10);
}
}
cbm_free_result(r);
PASS();
}
/* Astro's frontmatter fence and <script> bodies are TypeScript by default: an
* interface and typed signatures parse, and both blocks contribute in
* host-file coordinates. */
TEST(astro_embedded_structure_issue1807) {
CBMFileResult *r = extract("---\n"
"import Header from './Header.astro';\n"
"interface Props { title: string }\n"
"const { title }: Props = Astro.props;\n"
"function heading(): string { return format(title); }\n"
"---\n"
"<Header />\n"
"<script>\n"
"function hydrate(): void { heading(); }\n"
"</script>\n",
CBM_LANG_ASTRO, "t", "Page.astro");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def_any(r, "Props"));
ASSERT_EQ(count_defs_named(r, "Function", "heading"), 1);
ASSERT_EQ(count_defs_named(r, "Function", "hydrate"), 1);
ASSERT_EQ(count_calls_named(r, "format"), 1);
ASSERT_EQ(count_calls_named(r, "heading"), 1);
ASSERT_EQ(r->imports.count, 1);
ASSERT(has_import(r, "Header.astro"));
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (strcmp(d->name, "heading") == 0) {
ASSERT_EQ(d->start_line, 5);
} else if (strcmp(d->name, "hydrate") == 0) {
ASSERT_EQ(d->start_line, 9);
}
}
cbm_free_result(r);
PASS();
}
TEST(html_imports_basic) {
/* Plain HTML with inline ES module imports — same generic walker. */
CBMFileResult *r = extract("<!DOCTYPE html><html><head>\n"
@@ -7235,7 +7405,11 @@ SUITE(extraction) {
RUN_TEST(vue_imports_basic);
RUN_TEST(vue_embedded_structure_issue1410);
RUN_TEST(vue_embedded_structure_negative_controls_issue1410);
RUN_TEST(vue_embedded_structure_host_controls_issue1410);
RUN_TEST(embedded_structure_sibling_hosts_issue1807);
RUN_TEST(embedded_structure_inert_blocks_issue1807);
RUN_TEST(svelte_embedded_structure_both_blocks_issue1807);
RUN_TEST(html_embedded_structure_issue1807);
RUN_TEST(astro_embedded_structure_issue1807);
RUN_TEST(html_imports_basic);
/* config_extraction_test.go ports */
+4 -2
View File
@@ -236,8 +236,10 @@ static const LabelGolden LABEL_GOLDENS[] = {
{"liquid", "Module:1"},
{"blade", "Module:1"},
{"vue", "Module:1"},
{"svelte", "Module:1"},
{"astro", "Module:1"},
/* Svelte's <script> and Astro's frontmatter fence extract structure like a
* standalone script, so each fixture's one declaration is a Variable. */
{"svelte", "Module:1,Variable:1"},
{"astro", "Module:1,Variable:1"},
{"templ", "Class:1,Module:1"},
{"typst", "Module:1"},
{"mermaid", "Module:1"},