Merge pull request #9 from pineapplestrikesback/feat/svelte-import-parsing

feat: add Svelte and Vue import parsing to dependency graph

Thanks pineapplestrikesback, nice idea and nicely implemented with zero-dependencies.
This commit is contained in:
Giancarlo Erra
2026-03-18 13:45:28 +00:00
committed by GitHub
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", () => {