mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
c7e160cb5c
Add CSS @import extraction from Svelte/Vue <style> blocks and standalone CSS/SCSS/SASS/LESS files. Add path alias resolution from tsconfig.json / jsconfig.json compilerOptions.paths with extends chain support.
319 lines
9.0 KiB
TypeScript
319 lines
9.0 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { loadPathAliases, parsePathAliases } from "../../src/services/graph-aliases.js";
|
|
|
|
describe("parsePathAliases", () => {
|
|
const projectPath = "/project";
|
|
|
|
it("parses tsconfig paths with wildcard patterns", () => {
|
|
const json = JSON.stringify({
|
|
compilerOptions: {
|
|
paths: {
|
|
"$lib/*": ["src/lib/*"],
|
|
"@/*": ["./src/*"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
|
|
expect(aliases.entries.get("@/")).toEqual(["src"]);
|
|
});
|
|
|
|
it("respects baseUrl", () => {
|
|
const json = JSON.stringify({
|
|
compilerOptions: {
|
|
baseUrl: "./app",
|
|
paths: {
|
|
"@/*": ["src/*"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual([path.join("app", "src")]);
|
|
});
|
|
|
|
it("handles exact (non-wildcard) patterns", () => {
|
|
const json = JSON.stringify({
|
|
compilerOptions: {
|
|
paths: {
|
|
"~": ["./src"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("~")).toEqual(["src"]);
|
|
});
|
|
|
|
it("handles multiple targets for a single alias", () => {
|
|
const json = JSON.stringify({
|
|
compilerOptions: {
|
|
paths: {
|
|
"@/*": ["src/*", "generated/*"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual(["src", "generated"]);
|
|
});
|
|
|
|
it("handles JSON with single-line comments", () => {
|
|
const json = `{
|
|
// This is a comment
|
|
"compilerOptions": {
|
|
"paths": {
|
|
"$lib/*": ["src/lib/*"] // inline comment
|
|
}
|
|
}
|
|
}`;
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
|
|
});
|
|
|
|
it("handles JSON with multi-line comments", () => {
|
|
const json = `{
|
|
/* Multi-line
|
|
comment */
|
|
"compilerOptions": {
|
|
"paths": {
|
|
"@/*": ["src/*"]
|
|
}
|
|
}
|
|
}`;
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual(["src"]);
|
|
});
|
|
|
|
it("preserves // inside string values when stripping comments", () => {
|
|
const json = `{
|
|
// comment
|
|
"compilerOptions": {
|
|
"baseUrl": "./dist//subdir", // trailing comment
|
|
"paths": {
|
|
"@/*": ["src/*"]
|
|
}
|
|
}
|
|
}`;
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual([path.join("dist//subdir", "src")]);
|
|
});
|
|
|
|
it("returns empty entries for config without paths", () => {
|
|
const json = JSON.stringify({
|
|
compilerOptions: {
|
|
strict: true,
|
|
},
|
|
});
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.size).toBe(0);
|
|
});
|
|
|
|
it("returns empty entries for invalid JSON", () => {
|
|
const aliases = parsePathAliases("not json at all", projectPath);
|
|
|
|
expect(aliases.entries.size).toBe(0);
|
|
});
|
|
|
|
it("skips entries with empty targets array", () => {
|
|
const json = JSON.stringify({
|
|
compilerOptions: {
|
|
paths: {
|
|
"@/*": [],
|
|
"$lib/*": ["src/lib/*"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.has("@/")).toBe(false);
|
|
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
|
|
});
|
|
|
|
it("skips non-string targets gracefully", () => {
|
|
const json = '{"compilerOptions":{"paths":{"@/*":[42, null, "./src/*"]}}}';
|
|
|
|
const aliases = parsePathAliases(json, projectPath);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual(["src"]);
|
|
});
|
|
});
|
|
|
|
describe("loadPathAliases", () => {
|
|
let tmpDir: string | null = null;
|
|
|
|
afterEach(() => {
|
|
if (tmpDir) {
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
tmpDir = null;
|
|
}
|
|
});
|
|
|
|
it("returns empty aliases for non-existent project", async () => {
|
|
const aliases = await loadPathAliases("/non/existent/path");
|
|
|
|
expect(aliases.entries.size).toBe(0);
|
|
});
|
|
|
|
it("reads aliases from tsconfig.json", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "$lib/*": ["src/lib/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
|
|
});
|
|
|
|
it("falls back to jsconfig.json when tsconfig.json is absent", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "jsconfig.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "@/*": ["./src/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual(["src"]);
|
|
});
|
|
|
|
it("falls through to jsconfig.json when tsconfig.json has no paths", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
compilerOptions: { strict: true },
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "jsconfig.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "@/*": ["./src/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual(["src"]);
|
|
});
|
|
|
|
it("prefers tsconfig.json over jsconfig.json", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "$lib/*": ["src/lib/*"] },
|
|
},
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "jsconfig.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "@/*": ["./src/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
// Should use tsconfig.json paths, not jsconfig.json
|
|
expect(aliases.entries.has("$lib/")).toBe(true);
|
|
expect(aliases.entries.has("@/")).toBe(false);
|
|
});
|
|
|
|
it("follows extends chain to find paths in parent config", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
extends: "./tsconfig.base.json",
|
|
compilerOptions: { strict: true },
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.base.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "$lib/*": ["src/lib/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
|
|
});
|
|
|
|
it("follows multi-level extends chain", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
extends: "./tsconfig.app.json",
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.app.json"), JSON.stringify({
|
|
extends: "./tsconfig.base.json",
|
|
compilerOptions: { strict: true },
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.base.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "@/*": ["src/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.get("@/")).toEqual(["src"]);
|
|
});
|
|
|
|
it("handles extends without .json extension", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
extends: "./base",
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "base.json"), JSON.stringify({
|
|
compilerOptions: {
|
|
paths: { "$lib/*": ["src/lib/*"] },
|
|
},
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.get("$lib/")).toEqual(["src/lib"]);
|
|
});
|
|
|
|
it("handles circular extends gracefully", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
extends: "./tsconfig.base.json",
|
|
}));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.base.json"), JSON.stringify({
|
|
extends: "./tsconfig.json",
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
// Should not infinite loop, returns empty
|
|
expect(aliases.entries.size).toBe(0);
|
|
});
|
|
|
|
it("stops when extended file does not exist", async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-aliases-"));
|
|
fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({
|
|
extends: "./nonexistent.json",
|
|
}));
|
|
|
|
const aliases = await loadPathAliases(tmpDir);
|
|
|
|
expect(aliases.entries.size).toBe(0);
|
|
});
|
|
});
|