mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user