refactor(root): extract isOnPath and add extension-side server qualification

Extract isOnPath() to shared src/util.ts so both the daemon (client.ts)
and extension (root.ts) can use it. Add isServerAvailable() with a
per-process cache to pickServer(), skipping servers whose binary isn't
on PATH before sending requests to the daemon.

This avoids wasted daemon round-trips for missing binaries and sets up
for upcoming multi-server diagnostics fan-out.
This commit is contained in:
2026-05-04 07:24:59 -04:00
parent 630226a00a
commit d24e2e94f4
3 changed files with 44 additions and 28 deletions

View File

@@ -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

View File

@@ -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<string, boolean>();
// 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}`);
}

27
src/util.ts Normal file
View File

@@ -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;
}