Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/services/code-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
98 changes: 65 additions & 33 deletions src/services/graph-imports.ts
Original file line number Diff line number Diff line change
@@ -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 ───────────────────────────────────────
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 4 additions & 2 deletions src/services/graph-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 127 additions & 7 deletions tests/unit/graph-imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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);
});
});

Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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", () => {
Expand Down