diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts new file mode 100644 index 000000000..977b6af00 --- /dev/null +++ b/src/tools/lsp/config.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { isServerInstalled } from "./config" +import { mkdtempSync, rmSync, writeFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +describe("isServerInstalled", () => { + let tempDir: string + let savedEnv: { [key: string]: string | undefined } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "lsp-config-test-")) + savedEnv = { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + } + }) + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch (e) { + console.error(`Failed to clean up temp dir: ${e}`) + } + + const keys = ["PATH", "Path", "PATHEXT"] + for (const key of keys) { + const val = savedEnv[key] + if (val === undefined) { + delete process.env[key] + } else { + process.env[key] = val + } + } + }) + + test("detects executable in PATH", () => { + const binName = "test-lsp-server" + const ext = process.platform === "win32" ? ".cmd" : "" + const binPath = join(tempDir, binName + ext) + + writeFileSync(binPath, "echo hello") + + const pathSep = process.platform === "win32" ? ";" : ":" + process.env.PATH = `${tempDir}${pathSep}${process.env.PATH || ""}` + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("returns false for missing executable", () => { + expect(isServerInstalled(["non-existent-server"])).toBe(false) + }) + + if (process.platform === "win32") { + test("Windows: detects executable with Path env var", () => { + const binName = "test-lsp-server-case" + const binPath = join(tempDir, binName + ".cmd") + writeFileSync(binPath, "echo hello") + + delete process.env.PATH + process.env.Path = tempDir + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("Windows: respects PATHEXT", () => { + const binName = "test-lsp-server-custom" + const binPath = join(tempDir, binName + ".COM") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + process.env.PATHEXT = ".COM;.EXE" + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("Windows: ensures default extensions are checked even if PATHEXT is missing", () => { + const binName = "test-lsp-server-default" + const binPath = join(tempDir, binName + ".bat") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + delete process.env.PATHEXT + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("Windows: ensures default extensions are checked even if PATHEXT does not include them", () => { + const binName = "test-lsp-server-ps1" + const binPath = join(tempDir, binName + ".ps1") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + process.env.PATHEXT = ".COM" + + expect(isServerInstalled([binName])).toBe(true) + }) + } else { + test("Non-Windows: does not use windows extensions", () => { + const binName = "test-lsp-server-win" + const binPath = join(tempDir, binName + ".cmd") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + + expect(isServerInstalled([binName])).toBe(false) + }) + } +}) diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts index b29f9ac63..10a6febcf 100644 --- a/src/tools/lsp/config.ts +++ b/src/tools/lsp/config.ts @@ -170,31 +170,46 @@ export function isServerInstalled(command: string[]): boolean { } const isWindows = process.platform === "win32" - const ext = isWindows ? ".exe" : "" + + let exts = [""] + if (isWindows) { + const pathExt = process.env.PATHEXT || "" + if (pathExt) { + const systemExts = pathExt.split(";").filter(Boolean) + exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])] + } else { + exts = ["", ".exe", ".cmd", ".bat", ".ps1"] + } + } - const pathEnv = process.env.PATH || "" + let pathEnv = process.env.PATH || "" + if (isWindows && !pathEnv) { + pathEnv = process.env.Path || "" + } + const pathSeparator = isWindows ? ";" : ":" const paths = pathEnv.split(pathSeparator) for (const p of paths) { - if (existsSync(join(p, cmd)) || existsSync(join(p, cmd + ext))) { - return true + for (const suffix of exts) { + if (existsSync(join(p, cmd + suffix))) { + return true + } } } const cwd = process.cwd() - const additionalPaths = [ - join(cwd, "node_modules", ".bin", cmd), - join(cwd, "node_modules", ".bin", cmd + ext), - join(homedir(), ".config", "opencode", "bin", cmd), - join(homedir(), ".config", "opencode", "bin", cmd + ext), - join(homedir(), ".config", "opencode", "node_modules", ".bin", cmd), - join(homedir(), ".config", "opencode", "node_modules", ".bin", cmd + ext), + const additionalBases = [ + join(cwd, "node_modules", ".bin"), + join(homedir(), ".config", "opencode", "bin"), + join(homedir(), ".config", "opencode", "node_modules", ".bin"), ] - for (const p of additionalPaths) { - if (existsSync(p)) { - return true + for (const base of additionalBases) { + for (const suffix of exts) { + if (existsSync(join(base, cmd + suffix))) { + return true + } } }