fix(imports): extract ES imports from embedded <script> blocks (#224)

Generalizes #224's Svelte/Vue script-block import extraction into a
spec-driven embedded-language pattern that also covers HTML and Astro,
so any future host grammar that keeps sub-language source as raw text
gets coverage with a one-line declaration.

The existing CBMLangSpec already exposes the per-language tree-sitter
grammar via cbm_ts_language(); add a parallel embedded_imports field
that lists which AST node types hold which sub-language. The new
generic walker in extract_imports.c reads that list, locates each
embedded content node in the host AST, re-parses the slice with the
embedded grammar resolved through cbm_ts_language(), and runs the
standard ES import extractor (walk_es_imports) over the inner AST.
No grammar symbols are referenced from the extraction file.

- internal/cbm/lang_specs.h: add CBMEmbeddedLangSpec + embedded_imports
  field on CBMLangSpec.
- internal/cbm/lang_specs.c: declare vue/svelte/html/astro embed lists
  ({"script_element", "raw_text", CBM_LANG_JAVASCRIPT}); set the new
  field on those four spec entries; explicit NULL initializer on every
  other entry to satisfy -Wmissing-field-initializers.
- internal/cbm/extract_imports.c: add embedded_collect_content_nodes
  + parse_embedded_imports; route SVELTE/VUE/HTML/ASTRO through the
  generic helper.
- tests/test_extraction.c: cover Svelte (with and without <script>),
  Vue, and HTML.

Full suite: 2824 passed, 0 failed.

Closes #223. Closes #224.

Co-authored-by: James <jmcmacnz@gmail.com>
This commit is contained in:
Martin VogelandJames committed 2026-05-08 23:04:33 +02:00
1 parent aa6df6b906
commit 30941983cb
4 files changed
+385 -155

No files matched your search

+102
View File
@@ -1,6 +1,7 @@
#include "cbm.h"
#include "arena.h" // CBMArena, cbm_arena_strdup/strndup/sprintf
#include "helpers.h"
#include "lang_specs.h" // CBMLangSpec, CBMEmbeddedLangSpec, cbm_lang_spec, cbm_ts_language
#include "tree_sitter/api.h" // TSNode, ts_node_*
#include "foundation/constants.h"
#include "ts_node_stack.h"
@@ -846,6 +847,99 @@ static void parse_wolfram_imports(CBMExtractCtx *ctx) {
walk_wolfram_imports(ctx, ctx->root);
}
// --- 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.
//
// No grammar symbols are referenced here — the embedded TSLanguage is
// resolved through cbm_ts_language(spec->embedded_language), the same hook
// that the main parser uses. Adding another host language is a one-line
// declaration in lang_specs.c.
static void embedded_collect_content_nodes(TSNode root, const CBMEmbeddedLangSpec *spec,
TSNode *out, int *out_count, int max_out) {
/* Iterative DFS so deeply-nested script blocks are still found. Cap the
* stack to a sane bound (host grammars do not have million-deep markup
* trees) — no need to introduce TSNodeStack here. */
enum { EMBED_STACK_CAP = 1024 };
TSNode stack[EMBED_STACK_CAP];
int top = 0;
stack[top++] = root;
while (top > 0 && *out_count < max_out) {
TSNode node = stack[--top];
const char *kind = ts_node_type(node);
if (strcmp(kind, spec->script_node_type) == 0) {
uint32_t cc = ts_node_child_count(node);
for (uint32_t k = 0; k < cc; k++) {
TSNode c = ts_node_child(node, k);
if (strcmp(ts_node_type(c), spec->content_node_type) == 0) {
out[(*out_count)++] = c;
if (*out_count >= max_out) {
return;
}
break; /* one content node per script element */
}
}
/* Do not descend into <script>'s children — content already taken. */
continue;
}
uint32_t count = ts_node_child_count(node);
for (int i = (int)count - 1; i >= 0 && top < EMBED_STACK_CAP; i--) {
stack[top++] = ts_node_child(node, (uint32_t)i);
}
}
}
static void parse_embedded_imports(CBMExtractCtx *ctx) {
const CBMLangSpec *spec = cbm_lang_spec(ctx->language);
if (!spec || !spec->embedded_imports) {
return;
}
for (const CBMEmbeddedLangSpec *e = spec->embedded_imports; e->script_node_type != NULL; e++) {
const TSLanguage *embedded_lang = cbm_ts_language(e->embedded_language);
if (!embedded_lang) {
continue; /* embedded grammar not linked in — silently skip */
}
enum { MAX_EMBEDDED_BLOCKS = 16 };
TSNode hits[MAX_EMBEDDED_BLOCKS];
int hit_count = 0;
embedded_collect_content_nodes(ctx->root, e, hits, &hit_count, MAX_EMBEDDED_BLOCKS);
if (hit_count == 0) {
continue;
}
TSParser *parser = ts_parser_new();
if (!parser) {
continue;
}
if (!ts_parser_set_language(parser, embedded_lang)) {
ts_parser_delete(parser);
continue;
}
for (int i = 0; i < hit_count; i++) {
uint32_t s = ts_node_start_byte(hits[i]);
uint32_t end = ts_node_end_byte(hits[i]);
if (end <= s) {
continue;
}
const char *sub_src = ctx->source + s;
uint32_t sub_len = end - s;
TSTree *sub_tree = ts_parser_parse_string(parser, NULL, sub_src, sub_len);
if (!sub_tree) {
continue;
}
CBMExtractCtx sub_ctx = *ctx;
sub_ctx.source = sub_src;
sub_ctx.root = ts_tree_root_node(sub_tree);
walk_es_imports(&sub_ctx, sub_ctx.root);
ts_tree_delete(sub_tree);
}
ts_parser_delete(parser);
}
}
// --- Main dispatch ---
void cbm_extract_imports(CBMExtractCtx *ctx) {
@@ -937,6 +1031,14 @@ void cbm_extract_imports(CBMExtractCtx *ctx) {
case CBM_LANG_WOLFRAM:
parse_wolfram_imports(ctx);
break;
/* Host languages whose tree-sitter grammar leaves <script> bodies as raw
* text — re-parse the embedded slice via the embedded-language spec. */
case CBM_LANG_SVELTE:
case CBM_LANG_VUE:
case CBM_LANG_HTML:
case CBM_LANG_ASTRO:
parse_embedded_imports(ctx);
break;
default:
break;
}
+181 -155
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -3,6 +3,19 @@
#include "cbm.h"
// CBMEmbeddedLangSpec describes a sub-language embedded inside a host AST.
// Used by host grammars whose tree-sitter parser does not recurse into the
// embedded content (e.g. Svelte, Vue, Astro: script_element -> raw_text holds
// JavaScript verbatim that the host grammar leaves unparsed). The generic
// embedded-imports walker locates each script_node_type in the host AST,
// finds its content_node_type child, and re-parses that slice with the
// embedded_language's grammar so existing extractors run on the inner AST.
typedef struct {
const char *script_node_type; // e.g. "script_element"
const char *content_node_type; // e.g. "raw_text"
CBMLanguage embedded_language; // grammar used to re-parse the content slice
} CBMEmbeddedLangSpec;
// CBMLangSpec mirrors Go's lang.LanguageSpec with NULL-terminated string arrays.
typedef struct {
CBMLanguage language;
@@ -22,6 +35,10 @@ typedef struct {
const char **env_access_functions; // NULL-terminated (NULL if none)
const char **env_access_member_patterns; // NULL-terminated (NULL if none)
const TSLanguage *(*ts_factory)(void); // Tree-sitter grammar factory (NULL if shared)
// NULL-terminated list of embedded sub-languages (NULL if host grammar has
// no embedded content to re-parse). The terminator is an entry whose
// script_node_type is NULL.
const CBMEmbeddedLangSpec *embedded_imports;
} CBMLangSpec;
// Get the language spec for a given language. Returns NULL for unsupported.
+85
View File
@@ -1769,6 +1769,87 @@ TEST(import_stress_go) {
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Embedded-language import extraction
* Host grammars (Svelte, Vue, HTML, Astro) keep <script> bodies as
* raw_text — the embedded-imports walker re-parses each block with the
* JS grammar so the standard ES import extractor sees real
* import_statement nodes.
* ═══════════════════════════════════════════════════════════════════ */
TEST(svelte_imports_basic) {
/* Default import + named imports + namespace import */
CBMFileResult *r = extract(
"<script>\n"
"import Foo from './Foo.svelte';\n"
"import { bar, baz } from '../lib/utils';\n"
"import * as helpers from './helpers';\n"
"export let value = 42;\n"
"</script>\n"
"<h1>Hello {value}</h1>\n",
CBM_LANG_SVELTE, "t", "Comp.svelte");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_GTE(r->imports.count, 3);
ASSERT(has_import(r, "Foo.svelte"));
ASSERT(has_import(r, "lib/utils"));
ASSERT(has_import(r, "helpers"));
cbm_free_result(r);
PASS();
}
TEST(svelte_imports_no_script) {
/* .svelte with no <script> block must not crash, 0 imports */
CBMFileResult *r = extract(
"<h1>Static page</h1>\n"
"<p>No script here.</p>\n",
CBM_LANG_SVELTE, "t", "Static.svelte");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(r->imports.count, 0);
cbm_free_result(r);
PASS();
}
TEST(vue_imports_basic) {
/* Vue SFC: same document→script_element→raw_text AST structure */
CBMFileResult *r = extract(
"<template><div>{{ msg }}</div></template>\n"
"<script>\n"
"import MyComp from './MyComp.vue';\n"
"import { ref } from 'vue';\n"
"export default { name: 'App' };\n"
"</script>\n",
CBM_LANG_VUE, "t", "App.vue");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_GTE(r->imports.count, 2);
ASSERT(has_import(r, "MyComp.vue"));
ASSERT(has_import(r, "vue"));
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"
"<script type=\"module\">\n"
"import { renderApp } from './app.js';\n"
"import * as utils from './utils.js';\n"
"renderApp();\n"
"</script>\n"
"</head><body></body></html>\n",
CBM_LANG_HTML, "t", "index.html");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_GTE(r->imports.count, 2);
ASSERT(has_import(r, "app.js"));
ASSERT(has_import(r, "utils.js"));
cbm_free_result(r);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* config_extraction_test.go ports (25 tests)
* ═══════════════════════════════════════════════════════════════════ */
@@ -2297,6 +2378,10 @@ SUITE(extraction) {
RUN_TEST(ruby_imports);
RUN_TEST(lua_imports);
RUN_TEST(import_stress_go);
RUN_TEST(svelte_imports_basic);
RUN_TEST(svelte_imports_no_script);
RUN_TEST(vue_imports_basic);
RUN_TEST(html_imports_basic);
/* config_extraction_test.go ports */
RUN_TEST(toml_basic_table_and_pair);