Compare commits

...

3 Commits

Author SHA1 Message Date
e40c93fc80 feat(server): add diagnosticsOnly flag for lint-only servers
Add diagnosticsOnly?: boolean to ServerConfig. When set, the server is
excluded from pickServer() (hover/definition/references/completion/
documentSymbol) but still included in pickDiagnosticServers() for
lsp_diagnostics and auto-check.

Mark oxlint as diagnosticsOnly: true — it now contributes diagnostics
alongside typescript-language-server without interfering with navigation
or completion tools.
2026-05-04 07:41:39 -04:00
b9808a8b1f refactor(daemon): require explicit serverId on all daemon ops
Move all server matching logic to the extension/CLI side. The daemon no
longer calls pickServer() — it receives an explicit serverId (or
serverIds[] for diagnostics) and uses it directly for cache lookup and
server spawning.

Key changes:
- request op requires serverId: string
- diagnostics op requires serverIds: string[] — daemon fans out in
  parallel via Promise.allSettled and returns grouped map
- formatDiagnostics() handles grouped results with per-server headers
  when multiple servers contribute (single-server omits header)
- CLI picks servers locally before calling daemon helpers
- New pickDiagnosticServers() in extension returns all available,
  non-disabled servers matching the file extension

This makes multi-server diagnostics (e.g., typescript-language-server +
oxlint) work naturally — the extension decides which servers to query,
the daemon just executes.
2026-05-04 07:39:03 -04:00
d24e2e94f4 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.
2026-05-04 07:24:59 -04:00
10 changed files with 166 additions and 69 deletions

13
cli.ts
View File

@@ -2,7 +2,8 @@
import * as path from "node:path"; import * as path from "node:path";
import { startClientForFile } from "./src/client.ts"; import { startClientForFile } from "./src/client.ts";
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts"; import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
import { pickServer } from "./src/root.ts"; import { pickServer, isServerAvailable } from "./src/root.ts";
import { servers } from "./server.ts";
import { import {
daemonDiagnostics, daemonDiagnostics,
daemonRequest, daemonRequest,
@@ -104,14 +105,20 @@ async function runViaDaemon(
const filePath = path.resolve(fileArg); const filePath = path.resolve(fileArg);
let result: unknown; let result: unknown;
if (cmdArg === "diagnostics") { if (cmdArg === "diagnostics") {
result = await daemonDiagnostics(filePath); // Pick All Available Servers For Diagnostics
const ext = path.extname(filePath).replace(/^\./, "");
const serverIds = servers
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
.map((s) => s.id);
result = await daemonDiagnostics(filePath, serverIds);
} else if (cmdArg in methodMap) { } else if (cmdArg in methodMap) {
const server = pickServer(filePath);
// References Default - Match commands.ts: include declaration unless // References Default - Match commands.ts: include declaration unless
// caller explicitly overrode `context`. // caller explicitly overrode `context`.
if (cmdArg === "references" && !("context" in params)) { if (cmdArg === "references" && !("context" in params)) {
params.context = { includeDeclaration: true }; params.context = { includeDeclaration: true };
} }
result = await daemonRequest(filePath, methodMap[cmdArg], params); result = await daemonRequest(filePath, server.id, methodMap[cmdArg], params);
} else { } else {
process.stderr.write( process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`, `Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,

View File

@@ -12,7 +12,7 @@ import {
daemonRequest, daemonRequest,
daemonStatus, daemonStatus,
} from "./src/daemonClient.ts"; } from "./src/daemonClient.ts";
import { pickServer } from "./src/root.ts"; import { pickServer, isServerAvailable } from "./src/root.ts";
import { servers } from "./server.ts"; import { servers } from "./server.ts";
import { import {
ServerNotFoundError, ServerNotFoundError,
@@ -201,17 +201,8 @@ function formatDocumentSymbols(result: unknown): string {
} }
// Format Diagnostics - Turn diagnostic messages into readable text. // Format Diagnostics - Turn diagnostic messages into readable text.
function formatDiagnostics(result: unknown, limit = 20): string { // Format Single Server Diagnostics - Renders one server's diagnostics list.
if ( function formatServerDiagnostics(diags: any[], limit: number): string {
!result ||
typeof result !== "object" ||
!("diagnostics" in result)
) {
return "(no diagnostics)";
}
const diags = (result as any).diagnostics;
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
const severityNames: Record<number, string> = { const severityNames: Record<number, string> = {
1: "Error", 1: "Error",
2: "Warning", 2: "Warning",
@@ -236,11 +227,45 @@ function formatDiagnostics(result: unknown, limit = 20): string {
.join("\n"); .join("\n");
if (diags.length > limit) { if (diags.length > limit) {
return `${formatted}\n\n... and ${diags.length - limit} more diagnostics (showing first ${limit})`; return `${formatted}\n\n... and ${diags.length - limit} more (showing first ${limit})`;
} }
return formatted; return formatted;
} }
// Format Diagnostics - Handles the grouped result map from the daemon:
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
// header to avoid noise.
function formatDiagnostics(result: unknown, limit = 20): string {
if (!result || typeof result !== "object") return "(no diagnostics)";
const grouped = result as Record<string, any>;
const serverIds = Object.keys(grouped);
if (serverIds.length === 0) return "(no diagnostics)";
// Collect Servers With Diagnostics
const sections: { id: string; diags: any[] }[] = [];
for (const id of serverIds) {
const entry = grouped[id];
const diags = entry?.diagnostics;
if (Array.isArray(diags) && diags.length > 0) {
sections.push({ id, diags });
}
}
if (sections.length === 0) return "(no diagnostics)";
// Single Server - Skip header for brevity.
if (sections.length === 1) {
return formatServerDiagnostics(sections[0].diags, limit);
}
// Multiple Servers - Group with headers.
const perServer = Math.max(5, Math.floor(limit / sections.length));
return sections
.map((s) => `## ${s.id}\n${formatServerDiagnostics(s.diags, perServer)}`)
.join("\n\n");
}
// Is Expected Error - Returns true if the error is an expected condition // Is Expected Error - Returns true if the error is an expected condition
// (unsupported file type or missing server binary) that should be // (unsupported file type or missing server binary) that should be
// suppressed rather than surfaced to the user. // suppressed rather than surfaced to the user.
@@ -281,7 +306,7 @@ async function runLsp(
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`, `LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
); );
} }
return await daemonRequest(filePath, method, params); return await daemonRequest(filePath, server.id, method, params);
} catch (error) { } catch (error) {
if (isExpectedError(error)) { if (isExpectedError(error)) {
return undefined; return undefined;
@@ -300,19 +325,26 @@ async function runLsp(
} }
} }
// Run LSP Diagnostics - Diagnostics arrive as a notification, so the // Pick Diagnostic Servers - Returns all available, non-disabled servers
// daemon has a dedicated op that waits for the next publish. Expected // matching the file's extension. Used for fan-out diagnostics.
// errors (unsupported file type, missing binary) are suppressed. function pickDiagnosticServers(filePath: string): string[] {
const ext = path.extname(filePath).replace(/^\./, "");
return servers
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
.map((s) => s.id);
}
// Run LSP Diagnostics - Fans out to all matching servers in a single
// daemon call. Returns the grouped result map or undefined if no servers.
async function runDiagnostics(filePath: string): Promise<unknown> { async function runDiagnostics(filePath: string): Promise<unknown> {
try { try {
return await daemonDiagnostics(filePath, 1500); const serverIds = pickDiagnosticServers(filePath);
if (serverIds.length === 0) return undefined;
return await daemonDiagnostics(filePath, serverIds, 1500);
} catch (error) { } catch (error) {
if (isExpectedError(error)) { if (isExpectedError(error)) {
return undefined; return undefined;
} }
// Daemon-wrapped errors (plain Error with expected message) are also
// expected — the daemon catches pickServer() throws and returns them
// as string error messages.
if ( if (
error instanceof Error && error instanceof Error &&
(error.message.includes("No LSP server registered") || (error.message.includes("No LSP server registered") ||
@@ -492,9 +524,12 @@ export default function (pi: ExtensionAPI) {
try { try {
const absolutePath = path.resolve(ctx.cwd, filePath); const absolutePath = path.resolve(ctx.cwd, filePath);
// daemonDiagnostics triggers getOrCreateEntry + syncFile in the daemon. // Warm Diagnostic Servers - Fire-and-forget so servers are ready by
// We don't await it — just fire and forget so the server starts warming up. // the time an LSP tool is called.
void daemonDiagnostics(absolutePath).catch(() => {}); const serverIds = pickDiagnosticServers(absolutePath);
if (serverIds.length > 0) {
void daemonDiagnostics(absolutePath, serverIds).catch(() => {});
}
} catch { } catch {
// Silently ignore — unsupported file type, missing binary, etc. // Silently ignore — unsupported file type, missing binary, etc.
} }

View File

@@ -64,5 +64,6 @@ export const servers: ServerConfig[] = [
args: ["--lsp"], args: ["--lsp"],
rootMarkers: [".oxlintrc.json", "oxlint.config.json"], rootMarkers: [".oxlintrc.json", "oxlint.config.json"],
languageId: "typescript", languageId: "typescript",
diagnosticsOnly: true,
}, },
]; ];

View File

@@ -10,35 +10,10 @@ import type {
InitializeParams, InitializeParams,
PublishDiagnosticsParams, PublishDiagnosticsParams,
} from "vscode-languageserver-protocol"; } from "vscode-languageserver-protocol";
import * as path from "node:path";
import type { ServerConfig } from "./types.ts"; import type { ServerConfig } from "./types.ts";
import { ServerNotFoundError } from "./types.ts"; import { ServerNotFoundError } from "./types.ts";
import { findRoot, pathToUri, uriToPath } from "./root.ts"; import { findRoot, pathToUri, uriToPath } from "./root.ts";
import { isOnPath } from "./util.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;
}
// LspClient - Thin wrapper that spawns a language server, performs the // LspClient - Thin wrapper that spawns a language server, performs the
// initialize handshake, auto-opens a file, and exposes sendRequest so the // initialize handshake, auto-opens a file, and exposes sendRequest so the

View File

@@ -6,8 +6,9 @@ import * as fs from "node:fs";
import * as net from "node:net"; import * as net from "node:net";
import * as path from "node:path"; import * as path from "node:path";
import { LspClient } from "./client.ts"; import { LspClient } from "./client.ts";
import { findRoot, pickServer, pathToUri } from "./root.ts"; import { findRoot, pathToUri } from "./root.ts";
import type { ServerConfig } from "./types.ts"; import type { ServerConfig } from "./types.ts";
import { servers } from "../server.ts";
import { import {
logPath, logPath,
socketPath, socketPath,
@@ -50,14 +51,25 @@ function log(...args: unknown[]) {
); );
} }
// Get Or Create Entry - Looks up the cached client for a file, spawning a // Find Server By ID - Looks up a ServerConfig from the registry by ID.
// fresh LspClient if needed. The returned entry is guaranteed to have its // Throws if the ID is not registered.
// `ready` promise resolved before the caller uses it. function findServerById(serverId: string): ServerConfig {
const server = servers.find((s) => s.id === serverId);
if (!server) {
throw new Error(`Unknown server ID: "${serverId}". Registered: ${servers.map((s) => s.id).join(", ")}`);
}
return server;
}
// Get Or Create Entry - Looks up the cached client for a server+file,
// spawning a fresh LspClient if needed. The returned entry is guaranteed
// to have its `ready` promise resolved before the caller uses it.
async function getOrCreateEntry( async function getOrCreateEntry(
filePath: string, filePath: string,
serverId: string,
launch: LaunchContext, launch: LaunchContext,
): Promise<ClientEntry> { ): Promise<ClientEntry> {
const server = pickServer(filePath); const server = findServerById(serverId);
const rootDir = findRoot(filePath, server.rootMarkers); const rootDir = findRoot(filePath, server.rootMarkers);
const key = `${server.id}::${rootDir}`; const key = `${server.id}::${rootDir}`;
const existing = entries.get(key); const existing = entries.get(key);
@@ -161,7 +173,7 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
switch (req.op) { switch (req.op) {
case "request": { case "request": {
const filePath = path.resolve(req.file); const filePath = path.resolve(req.file);
const entry = await getOrCreateEntry(filePath, req.launch); const entry = await getOrCreateEntry(filePath, req.serverId, req.launch);
const { uri } = await syncFile(entry, filePath); const { uri } = await syncFile(entry, filePath);
bumpIdle(entry); bumpIdle(entry);
const result = await entry.client.sendRequest( const result = await entry.client.sendRequest(
@@ -172,15 +184,27 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
} }
case "diagnostics": { case "diagnostics": {
const filePath = path.resolve(req.file); const filePath = path.resolve(req.file);
const entry = await getOrCreateEntry(filePath, req.launch); const timeoutMs = req.timeoutMs ?? 1500;
const { uri, changed } = await syncFile(entry, filePath);
bumpIdle(entry); // Fan-Out - Run diagnostics against all requested servers in
if (changed) entry.client.clearDiagnostics(uri); // parallel. Individual failures are captured, not thrown.
const result = await entry.client.waitForDiagnostics( const results: Record<string, unknown> = {};
uri, const settled = await Promise.allSettled(
req.timeoutMs ?? 1500, req.serverIds.map(async (serverId) => {
const entry = await getOrCreateEntry(filePath, serverId, req.launch);
const { uri, changed } = await syncFile(entry, filePath);
bumpIdle(entry);
if (changed) entry.client.clearDiagnostics(uri);
const diag = await entry.client.waitForDiagnostics(uri, timeoutMs);
return { serverId, diag };
}),
); );
return { id: req.id, ok: true, result }; for (const outcome of settled) {
if (outcome.status === "fulfilled") {
results[outcome.value.serverId] = outcome.value.diag;
}
}
return { id: req.id, ok: true, result: results };
} }
case "status": { case "status": {
const result = { const result = {

View File

@@ -22,6 +22,7 @@ function unwrap(resp: DaemonResponse): unknown {
// daemon injects textDocument.uri from `file`, so callers omit it. // daemon injects textDocument.uri from `file`, so callers omit it.
export async function daemonRequest( export async function daemonRequest(
file: string, file: string,
serverId: string,
method: string, method: string,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<unknown> { ): Promise<unknown> {
@@ -29,6 +30,7 @@ export async function daemonRequest(
await sendOnce({ await sendOnce({
op: "request", op: "request",
file, file,
serverId,
method, method,
params, params,
launch: buildLaunchContext(), launch: buildLaunchContext(),
@@ -38,14 +40,18 @@ export async function daemonRequest(
// Wait For Diagnostics - Diagnostics arrive as a notification, not a // Wait For Diagnostics - Diagnostics arrive as a notification, not a
// response, so the daemon has a dedicated op that awaits the next publish. // response, so the daemon has a dedicated op that awaits the next publish.
// Accepts an array of server IDs; daemon fans out in parallel and returns
// a grouped map: { [serverId]: { uri, diagnostics[] } }.
export async function daemonDiagnostics( export async function daemonDiagnostics(
file: string, file: string,
serverIds: string[],
timeoutMs = 1500, timeoutMs = 1500,
): Promise<unknown> { ): Promise<unknown> {
return unwrap( return unwrap(
await sendOnce({ await sendOnce({
op: "diagnostics", op: "diagnostics",
file, file,
serverIds,
timeoutMs, timeoutMs,
launch: buildLaunchContext(), launch: buildLaunchContext(),
}), }),

View File

@@ -38,6 +38,7 @@ export type DaemonRequest =
id: number; id: number;
op: "request"; op: "request";
file: string; file: string;
serverId: string;
method: string; method: string;
params: Record<string, unknown>; params: Record<string, unknown>;
launch: LaunchContext; launch: LaunchContext;
@@ -46,6 +47,7 @@ export type DaemonRequest =
id: number; id: number;
op: "diagnostics"; op: "diagnostics";
file: string; file: string;
serverIds: string[];
timeoutMs?: number; timeoutMs?: number;
launch: LaunchContext; launch: LaunchContext;
} }
@@ -57,6 +59,7 @@ export type DaemonRequestWithoutId =
| { | {
op: "request"; op: "request";
file: string; file: string;
serverId: string;
method: string; method: string;
params: Record<string, unknown>; params: Record<string, unknown>;
launch: LaunchContext; launch: LaunchContext;
@@ -64,6 +67,7 @@ export type DaemonRequestWithoutId =
| { | {
op: "diagnostics"; op: "diagnostics";
file: string; file: string;
serverIds: string[];
timeoutMs?: number; timeoutMs?: number;
launch: LaunchContext; launch: LaunchContext;
} }

View File

@@ -4,6 +4,7 @@ import { pathToFileURL, fileURLToPath } from "node:url";
import { servers, globalRootMarkers } from "../server.ts"; import { servers, globalRootMarkers } from "../server.ts";
import type { ServerConfig } from "./types.ts"; import type { ServerConfig } from "./types.ts";
import { UnsupportedExtensionError } from "./types.ts"; import { UnsupportedExtensionError } from "./types.ts";
import { isOnPath } from "./util.ts";
// Resolve File URI To Local Path // Resolve File URI To Local Path
export function uriToPath(uri: string): string { export function uriToPath(uri: string): string {
@@ -15,11 +16,24 @@ export function pathToUri(p: string): string {
return pathToFileURL(path.resolve(p)).toString(); 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 // 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, non-diagnosticsOnly server wins.
export function pickServer(filePath: string): ServerConfig { export function pickServer(filePath: string): ServerConfig {
const ext = path.extname(filePath).replace(/^\./, ""); const ext = path.extname(filePath).replace(/^\./, "");
const hit = servers.find((s) => s.match.includes(ext)); const hit = servers.find((s) => s.match.includes(ext) && !s.diagnosticsOnly && isServerAvailable(s));
if (!hit) { if (!hit) {
throw new UnsupportedExtensionError(`.${ext}`); throw new UnsupportedExtensionError(`.${ext}`);
} }

View File

@@ -34,6 +34,10 @@ export interface ServerConfig {
// Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts // Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts
// it after this many ms of inactivity. Defaults to 5 minutes. // it after this many ms of inactivity. Defaults to 5 minutes.
idleTtlMs?: number; idleTtlMs?: number;
// Diagnostics Only - When true, this server is excluded from
// hover/definition/references/completion/documentSymbol but included
// in lsp_diagnostics and auto-check.
diagnosticsOnly?: boolean;
} }
// Supported high-level commands exposed via the CLI. Extend this union // Supported high-level commands exposed via the CLI. Extend this union

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