diff --git a/src/client.ts b/src/client.ts index a2b5e79..d5d8cc6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,35 +10,10 @@ import type { InitializeParams, PublishDiagnosticsParams, } from "vscode-languageserver-protocol"; -import * as path from "node:path"; import type { ServerConfig } from "./types.ts"; import { ServerNotFoundError } from "./types.ts"; import { findRoot, pathToUri, uriToPath } from "./root.ts"; - -// Is On PATH - Returns true if `cmd` resolves to an executable via the -// supplied PATH. Absolute/relative paths are checked directly. -function isOnPath(cmd: string, env: NodeJS.ProcessEnv): boolean { - const isExec = (p: string) => { - try { - fs.accessSync(p, fs.constants.X_OK); - return true; - } catch { - return false; - } - }; - if (cmd.includes(path.sep)) return isExec(cmd); - const exts = - process.platform === "win32" - ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") - : [""]; - for (const dir of (env.PATH ?? "").split(path.delimiter)) { - if (!dir) continue; - for (const ext of exts) { - if (isExec(path.join(dir, cmd + ext))) return true; - } - } - return false; -} +import { isOnPath } from "./util.ts"; // LspClient - Thin wrapper that spawns a language server, performs the // initialize handshake, auto-opens a file, and exposes sendRequest so the diff --git a/src/root.ts b/src/root.ts index 3cdb3d3..ae5eb74 100644 --- a/src/root.ts +++ b/src/root.ts @@ -4,6 +4,7 @@ import { pathToFileURL, fileURLToPath } from "node:url"; import { servers, globalRootMarkers } from "../server.ts"; import type { ServerConfig } from "./types.ts"; import { UnsupportedExtensionError } from "./types.ts"; +import { isOnPath } from "./util.ts"; // Resolve File URI To Local Path export function uriToPath(uri: string): string { @@ -15,11 +16,24 @@ export function pathToUri(p: string): string { return pathToFileURL(path.resolve(p)).toString(); } +// Server Availability Cache - Checked once per process lifetime per server. +// Avoids repeated filesystem lookups on every tool call. +const serverAvailability = new Map(); + +// Is Server Available - Returns true if the server binary is on PATH. +// Result is cached for the lifetime of this process. +export function isServerAvailable(server: ServerConfig): boolean { + if (serverAvailability.has(server.id)) return serverAvailability.get(server.id)!; + const available = isOnPath(server.command, process.env); + serverAvailability.set(server.id, available); + return available; +} + // Pick Server By File Extension - match[] entries are matched against the -// file's extension (no dot). First server in the registry wins. +// file's extension (no dot). First available server in the registry wins. export function pickServer(filePath: string): ServerConfig { const ext = path.extname(filePath).replace(/^\./, ""); - const hit = servers.find((s) => s.match.includes(ext)); + const hit = servers.find((s) => s.match.includes(ext) && isServerAvailable(s)); if (!hit) { throw new UnsupportedExtensionError(`.${ext}`); } diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..300bb37 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,27 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Is On PATH - Returns true if `cmd` resolves to an executable via the +// supplied PATH. Absolute/relative paths are checked directly. +export function isOnPath(cmd: string, env: NodeJS.ProcessEnv): boolean { + const isExec = (p: string) => { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } + }; + if (cmd.includes(path.sep)) return isExec(cmd); + const exts = + process.platform === "win32" + ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") + : [""]; + for (const dir of (env.PATH ?? "").split(path.delimiter)) { + if (!dir) continue; + for (const ext of exts) { + if (isExec(path.join(dir, cmd + ext))) return true; + } + } + return false; +}