import { describe, expect, it } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { loadConfig, type ConfigPaths } from "./config.js"; function makePaths(overrides: Partial = {}): ConfigPaths & { cleanup: () => void } { const base = mkdtempSync(path.join(tmpdir(), "pi-lsp-config-")); const cwd = path.join(base, "project"); mkdirSync(cwd, { recursive: true }); return { cwd, trusted: true, userConfigPath: path.join(base, "user", "pi-lsp.json"), projectConfigPath: path.join(cwd, ".pi", "pi-lsp.json"), ...overrides, cleanup: () => rmSync(base, { recursive: true, force: true }), }; } function writeConfig(file: string, json: unknown) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(json, null, 2), "utf8"); } describe("loadConfig", () => { it("returns the default official-server catalog when no config files exist", () => { const paths = makePaths(); try { const config = loadConfig(paths); expect(config.timeout).toBe(30000); expect(config.progressive.enabled).toBe(true); expect(config.progressive.inject).toBe("status"); // Official TypeScript server by default — never biome. const ts = config.servers.typescript; expect(ts).toBeDefined(); expect(ts.command).toEqual(["typescript-language-server", "--stdio"]); expect(ts.extensions).toContain(".ts"); expect(ts.extensions).toContain(".tsx"); // Kotlin and other non-TypeScript languages are first-class defaults. const kotlin = config.servers.kotlin; expect(kotlin).toBeDefined(); expect(kotlin.extensions).toEqual([".kt", ".kts"]); expect(kotlin.command).toEqual(["kotlin-lsp"]); expect(config.servers.gopls).toBeDefined(); expect(config.servers["rust-analyzer"]).toBeDefined(); expect(config.servers.clangd).toBeDefined(); expect(config.servers.pyright).toBeDefined(); } finally { paths.cleanup(); } }); it("user config overrides a server command and extensions", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, { servers: { typescript: { command: ["custom-ts", "--stdio"], extensions: [".ts", ".tsx"], }, }, }); const config = loadConfig(paths); expect(config.servers.typescript.command).toEqual(["custom-ts", "--stdio"]); expect(config.servers.typescript.extensions).toEqual([".ts", ".tsx"]); // Other defaults untouched. expect(config.servers.kotlin.command).toEqual(["kotlin-lsp"]); } finally { paths.cleanup(); } }); it("project config takes precedence over user config when the project is trusted", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, { servers: { typescript: { command: ["user-ts"] } }, }); writeConfig(paths.projectConfigPath!, { servers: { typescript: { command: ["project-ts"] } }, }); const config = loadConfig(paths); expect(config.servers.typescript.command).toEqual(["project-ts"]); } finally { paths.cleanup(); } }); it("project config is ignored when the project is untrusted", () => { const paths = makePaths({ trusted: false }); try { writeConfig(paths.projectConfigPath!, { servers: { typescript: { command: ["sneaky-ts"] } }, }); const config = loadConfig(paths); expect(config.servers.typescript.command).toEqual(["typescript-language-server", "--stdio"]); } finally { paths.cleanup(); } }); it("disabled removes a default server", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, { servers: { kotlin: { disabled: true } }, }); const config = loadConfig(paths); expect(config.servers.kotlin).toBeUndefined(); expect(config.servers.typescript).toBeDefined(); } finally { paths.cleanup(); } }); it("unknown server ids in config are added to the catalog", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, { servers: { "my-lang": { command: ["my-lang-lsp"], extensions: [".mylang"] } }, }); const config = loadConfig(paths); expect(config.servers["my-lang"]).toBeDefined(); expect(config.servers["my-lang"].command).toEqual(["my-lang-lsp"]); } finally { paths.cleanup(); } }); it("progressive and timeout are configurable", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, { timeout: 10000, progressive: { enabled: false, inject: "conversation", maxDiagnostics: 5, quietMs: 500 }, }); const config = loadConfig(paths); expect(config.timeout).toBe(10000); expect(config.progressive).toEqual({ enabled: false, inject: "conversation", maxDiagnostics: 5, quietMs: 500, }); } finally { paths.cleanup(); } }); it("an invalid config file falls back to defaults for that source", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, "not json {{{"); const config = loadConfig(paths); expect(config.servers.typescript.command).toEqual(["typescript-language-server", "--stdio"]); expect(config.timeout).toBe(30000); } finally { paths.cleanup(); } }); it("binDir defaults to the managed cache directory", () => { const paths = makePaths(); try { const config = loadConfig({ ...paths, env: { XDG_CACHE_HOME: "/x/cache" } }); expect(config.binDir).toBe("/x/cache/pi-lsp/bin"); } finally { paths.cleanup(); } }); it("binDir is overridable in config", () => { const paths = makePaths(); try { writeConfig(paths.userConfigPath!, { binDir: "/custom/tools" }); const config = loadConfig({ ...paths, env: { XDG_CACHE_HOME: "/x/cache" } }); expect(config.binDir).toBe("/custom/tools"); } finally { paths.cleanup(); } }); });