feat: add Svelte and Vue import parsing to dependency graph

Svelte and Vue files were included in the graph as leaf nodes (targets
of import edges) but their own imports were never extracted because
no ast-grep grammar exists for these languages.

This adds support by parsing .svelte/.vue files as HTML (built-in
grammar), extracting <script> block content, and re-parsing it as
TypeScript to extract imports using the existing JS/TS logic.

Changes:
- code-graph.ts: register .svelte and .vue in getAstGrepLang()
- graph-imports.ts: add Svelte/Vue script extraction handler; refactor
  JS/TS import extraction into shared extractJsTsImportsFromNode()
  helper to avoid duplication
- graph-resolution.ts: add "svelte" and "vue" cases to resolveImport(),
  with .svelte/.vue as first-priority extension resolution
- graph-imports.test.ts: add tests for Svelte (static, dynamic, module,
  no-script, JS-only) and Vue import extraction

Zero new dependencies — uses the built-in Lang.Html and Lang.TypeScript
grammars from @ast-grep/napi.

Known limitation: path aliases ($lib/, @/) are not resolved and will be
treated as external packages (same as current JS/TS behavior).
This commit is contained in:
pineapplestrikesback
2026-03-16 16:23:18 +01:00
parent 06f5dee474
commit 4c2bd0cc53
4 changed files with 199 additions and 42 deletions
+3
View File
@@ -305,6 +305,9 @@ export function getAstGrepLang(ext: string): Lang | string | null {
".dart": "dart",
".lua": "lua",
".sh": "bash", ".bash": "bash", ".zsh": "bash",
// Composite languages (parsed via HTML + script re-parse)
".svelte": "svelte",
".vue": "vue",
// Built-in languages (Lang enum)
".js": Lang.JavaScript, ".jsx": Lang.JavaScript, ".mjs": Lang.JavaScript, ".cjs": Lang.JavaScript,
".ts": Lang.TypeScript,
+65 -33
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { type Lang, parse } from "@ast-grep/napi";
import { Lang, parse } from "@ast-grep/napi";
import { logger } from "./logger.js";
// ── Import extraction per language ───────────────────────────────────────
@@ -10,6 +10,46 @@ export interface ImportInfo {
isDynamic: boolean;
}
/** Extract JS/TS imports from an ast-grep root node. Shared by JS/TS and Svelte/Vue handlers. */
function extractJsTsImportsFromNode(sgNode: ReturnType<ReturnType<typeof parse>["root"]>): ImportInfo[] {
const imports: ImportInfo[] = [];
// import ... from "..."
for (const node of sgNode.findAll({ rule: { kind: "import_statement" } })) {
const sourceNode = node.find({ rule: { kind: "string" } });
if (sourceNode) {
const spec = sourceNode.text().replace(/['"]/g, "");
imports.push({ moduleSpecifier: spec, isDynamic: false });
}
}
// require("...")
for (const node of sgNode.findAll({ rule: { kind: "call_expression" } })) {
const text = node.text();
const match = text.match(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
if (match) {
imports.push({ moduleSpecifier: match[1], isDynamic: false });
}
}
// dynamic import("...")
for (const node of sgNode.findAll({ rule: { kind: "call_expression" } })) {
const text = node.text();
const match = text.match(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/);
if (match) {
imports.push({ moduleSpecifier: match[1], isDynamic: true });
}
}
// export ... from "..."
for (const node of sgNode.findAll({ rule: { kind: "export_statement" } })) {
const sourceNode = node.find({ rule: { kind: "string" } });
if (sourceNode) {
const spec = sourceNode.text().replace(/['"]/g, "");
imports.push({ moduleSpecifier: spec, isDynamic: false });
}
}
return imports;
}
/**
* Extract import statements from source code using ast-grep.
* Returns raw module specifiers for each language's import syntax.
@@ -43,6 +83,29 @@ export function extractImports(source: string, lang: Lang | string, _ext: string
return imports;
}
// ── Svelte/Vue: parse as HTML, extract <script> blocks, re-parse as TS ──
if (langKey === "svelte" || langKey === "vue") {
try {
const htmlRoot = parse(Lang.Html, source).root();
const scriptElements = htmlRoot.findAll({ rule: { kind: "script_element" } });
for (const scriptEl of scriptElements) {
const rawText = scriptEl.find({ rule: { kind: "raw_text" } });
if (!rawText) continue;
const scriptContent = rawText.text();
if (!scriptContent.trim()) continue;
// Default to TypeScript (superset of JS, safe for both)
const scriptRoot = parse(Lang.TypeScript, scriptContent).root();
imports.push(...extractJsTsImportsFromNode(scriptRoot));
}
} catch (err) {
logger.warn("Failed to parse Svelte/Vue file for imports", { error: String(err) });
}
return imports;
}
// ── AST-based extraction for languages with grammar support ───────────
try {
const sgNode = parse(lang, source).root();
@@ -74,38 +137,7 @@ export function extractImports(source: string, lang: Lang | string, _ext: string
case "JavaScript":
case "TypeScript":
case "Tsx": {
// import ... from "..."
for (const node of sgNode.findAll({ rule: { kind: "import_statement" } })) {
const sourceNode = node.find({ rule: { kind: "string" } });
if (sourceNode) {
const spec = sourceNode.text().replace(/['"]/g, "");
imports.push({ moduleSpecifier: spec, isDynamic: false });
}
}
// require("...")
for (const node of sgNode.findAll({ rule: { kind: "call_expression" } })) {
const text = node.text();
const match = text.match(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
if (match) {
imports.push({ moduleSpecifier: match[1], isDynamic: false });
}
}
// dynamic import("...")
for (const node of sgNode.findAll({ rule: { kind: "call_expression" } })) {
const text = node.text();
const match = text.match(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/);
if (match) {
imports.push({ moduleSpecifier: match[1], isDynamic: true });
}
}
// export ... from "..."
for (const node of sgNode.findAll({ rule: { kind: "export_statement" } })) {
const sourceNode = node.find({ rule: { kind: "string" } });
if (sourceNode) {
const spec = sourceNode.text().replace(/['"]/g, "");
imports.push({ moduleSpecifier: spec, isDynamic: false });
}
}
imports.push(...extractJsTsImportsFromNode(sgNode));
break;
}
+4 -2
View File
@@ -22,11 +22,13 @@ export function resolveImport(
switch (language) {
case "javascript":
case "typescript": {
case "typescript":
case "svelte":
case "vue": {
// Relative imports: ./foo, ../bar
if (moduleSpecifier.startsWith(".")) {
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, [
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".svelte", ".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
]);
}
return null; // npm packages
+127 -7
View File
@@ -4,7 +4,7 @@
import { Lang } from "@ast-grep/napi";
import { beforeAll, describe, expect, it } from "vitest";
import { ensureDynamicLanguages } from "../../src/services/code-graph.js";
import { extractImports, } from "../../src/services/graph-imports.js";
import { extractImports } from "../../src/services/graph-imports.js";
// Register dynamic language grammars once before all tests
beforeAll(() => {
@@ -37,7 +37,9 @@ const mod = await import("./dynamic-module.js");
const dynamicImports = imports.filter((i) => i.isDynamic);
expect(dynamicImports.length).toBeGreaterThanOrEqual(1);
expect(dynamicImports.some((i) => i.moduleSpecifier === "./dynamic-module.js")).toBe(true);
expect(
dynamicImports.some((i) => i.moduleSpecifier === "./dynamic-module.js"),
).toBe(true);
});
it("extracts require() calls", () => {
@@ -80,6 +82,112 @@ function hello() {
});
});
// ── Svelte ──────────────────────────────────────────────────────────────
describe("Svelte imports", () => {
it("extracts imports from <script> blocks", () => {
const source = `
<script lang="ts">
import { onMount } from "svelte";
import Button from "./Button.svelte";
import { type Props } from "../types.js";
</script>
<Button>Click me</Button>
`;
const imports = extractImports(source, "svelte", ".svelte");
const specs = imports.map((i) => i.moduleSpecifier);
expect(specs).toContain("svelte");
expect(specs).toContain("./Button.svelte");
expect(specs).toContain("../types.js");
});
it("extracts imports from <script module> blocks", () => {
const source = `
<script lang="ts" module>
export type Variant = "primary" | "secondary";
export { default as Button } from "./Button.svelte";
</script>
<script lang="ts">
import { onMount } from "svelte";
</script>
<div>content</div>
`;
const imports = extractImports(source, "svelte", ".svelte");
const specs = imports.map((i) => i.moduleSpecifier);
expect(specs).toContain("./Button.svelte");
expect(specs).toContain("svelte");
});
it("extracts dynamic imports from Svelte files", () => {
const source = `
<script lang="ts">
const Component = await import("./DynamicComponent.svelte");
</script>
`;
const imports = extractImports(source, "svelte", ".svelte");
const dynamicImports = imports.filter((i) => i.isDynamic);
expect(dynamicImports.length).toBeGreaterThanOrEqual(1);
expect(
dynamicImports.some(
(i) => i.moduleSpecifier === "./DynamicComponent.svelte",
),
).toBe(true);
});
it("handles Svelte files with no script block", () => {
const source = `
<div>Just markup, no script</div>
<style>
div { color: red; }
</style>
`;
const imports = extractImports(source, "svelte", ".svelte");
expect(imports).toHaveLength(0);
});
it("handles Svelte files with JavaScript (no lang=ts)", () => {
const source = `
<script>
import { writable } from "svelte/store";
import Item from "./Item.svelte";
</script>
`;
const imports = extractImports(source, "svelte", ".svelte");
const specs = imports.map((i) => i.moduleSpecifier);
expect(specs).toContain("svelte/store");
expect(specs).toContain("./Item.svelte");
});
});
// ── Vue ────────────────────────────────────────────────────────────────
describe("Vue imports", () => {
it("extracts imports from <script> blocks", () => {
const source = `
<script lang="ts">
import { ref, computed } from "vue";
import MyComponent from "./MyComponent.vue";
</script>
<template>
<MyComponent />
</template>
`;
const imports = extractImports(source, "vue", ".vue");
const specs = imports.map((i) => i.moduleSpecifier);
expect(specs).toContain("vue");
expect(specs).toContain("./MyComponent.vue");
});
});
// ── Python ─────────────────────────────────────────────────────────────
describe("Python imports", () => {
@@ -342,8 +450,12 @@ import kotlinx.coroutines.launch
const specs = imports.map((i) => i.moduleSpecifier);
expect(specs.length).toBeGreaterThanOrEqual(3);
expect(specs.some((s) => s.includes("com.example.models.User"))).toBe(true);
expect(specs.some((s) => s.includes("com.example.utils.StringHelper"))).toBe(true);
expect(specs.some((s) => s.includes("com.example.models.User"))).toBe(
true,
);
expect(
specs.some((s) => s.includes("com.example.utils.StringHelper")),
).toBe(true);
});
it("handles wildcard imports", () => {
@@ -353,7 +465,9 @@ import com.example.models.*
const imports = extractImports(source, "kotlin", ".kt");
expect(imports.length).toBeGreaterThanOrEqual(1);
expect(imports.some((i) => i.moduleSpecifier.includes("com.example.models"))).toBe(true);
expect(
imports.some((i) => i.moduleSpecifier.includes("com.example.models")),
).toBe(true);
});
});
@@ -372,7 +486,11 @@ import com.example.services._
const specs = imports.map((i) => i.moduleSpecifier);
expect(specs.length).toBeGreaterThanOrEqual(2);
expect(specs.some((s) => s.includes("scala.collection") || s.includes("ListBuffer"))).toBe(true);
expect(
specs.some(
(s) => s.includes("scala.collection") || s.includes("ListBuffer"),
),
).toBe(true);
});
});
@@ -429,7 +547,9 @@ using static System.Math;
const imports = extractImports(source, "csharp", ".cs");
expect(imports.length).toBeGreaterThanOrEqual(1);
expect(imports.some((i) => i.moduleSpecifier.includes("System.Math"))).toBe(true);
expect(
imports.some((i) => i.moduleSpecifier.includes("System.Math")),
).toBe(true);
});
it("skips using alias directives", () => {