initial commit

This commit is contained in:
2026-04-25 21:06:15 -04:00
commit 61bca87bba
12 changed files with 1738 additions and 0 deletions

258
src/client.ts Normal file
View File

@@ -0,0 +1,258 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import * as fs from "node:fs";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
type MessageConnection,
} from "vscode-jsonrpc/node.js";
import type {
InitializeParams,
PublishDiagnosticsParams,
} from "vscode-languageserver-protocol";
import * as path from "node:path";
import type { ServerConfig } from "./types.ts";
import { findRoot, pathToUri, uriToPath } from "./root.ts";
// Is On PATH - Returns true if `cmd` resolves to an executable via the
// current PATH. Absolute/relative paths are checked directly.
function isOnPath(cmd: string): 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"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
: [""];
for (const dir of (process.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
// initialize handshake, auto-opens a file, and exposes sendRequest so the
// command dispatcher can forward raw LSP method calls.
//
// Method Names As Strings - We use plain method strings rather than the
// typed ProtocolRequestType constants to sidestep a version skew between
// vscode-jsonrpc and the copy re-exported by vscode-languageserver-protocol.
//
// Lifetime - One client per CLI invocation. On dispose() we do a best-effort
// shutdown/exit. A future daemon will reuse a client per (server.id, rootUri).
export class LspClient {
private proc!: ChildProcessWithoutNullStreams;
private conn!: MessageConnection;
private diagnostics = new Map<string, PublishDiagnosticsParams>();
private diagListeners = new Set<(p: PublishDiagnosticsParams) => void>();
// Progress Tracking - Servers like gopls aren't ready to serve requests
// until they finish their initial workspace load. They announce this via
// `$/progress` end notifications. We track outstanding tokens so callers
// can await readiness.
private progressTokens = new Set<string | number>();
private progressListeners = new Set<() => void>();
constructor(private readonly server: ServerConfig) {}
// Start - Spawns the server process and wires up JSON-RPC.
async start(rootDir: string): Promise<void> {
// Verify Binary On PATH - Fail fast with a clear message instead of
// letting spawn ENOENT surface as a generic error. It's the user's
// responsibility to have the server installed & on PATH.
if (!isOnPath(this.server.command)) {
throw new Error(
`LSP server binary "${this.server.command}" not found on PATH. ` +
`Install it and ensure it's on your PATH (required by server "${this.server.id}").`,
);
}
this.proc = spawn(this.server.command, this.server.args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: rootDir,
});
this.proc.on("error", (err) => {
process.stderr.write(
`[lsp:${this.server.id}] spawn error: ${err.message}\n`,
);
});
// Drain Server Stderr - Many servers log verbosely; only forward when
// LSP_DEBUG is set so normal output stays clean.
this.proc.stderr.on("data", (chunk) => {
if (process.env.LSP_DEBUG) process.stderr.write(chunk);
});
this.conn = createMessageConnection(
new StreamMessageReader(this.proc.stdout),
new StreamMessageWriter(this.proc.stdin),
);
// Capture Diagnostics - Stored by URI, also fanned out to listeners so
// the diagnostics command can await the first publish.
this.conn.onNotification(
"textDocument/publishDiagnostics",
(p: PublishDiagnosticsParams) => {
this.diagnostics.set(p.uri, p);
for (const l of this.diagListeners) l(p);
},
);
// Track Progress Tokens - Count begin/end so we know when the server
// has finished all startup work. We accept every createWorkDoneProgress
// request so servers (gopls) will actually send progress.
this.conn.onRequest(
"window/workDoneProgress/create",
(p: { token: string | number }) => {
this.progressTokens.add(p.token);
return null;
},
);
this.conn.onNotification(
"$/progress",
(p: { token: string | number; value: { kind: string } }) => {
if (p.value?.kind === "begin") this.progressTokens.add(p.token);
else if (p.value?.kind === "end") {
this.progressTokens.delete(p.token);
for (const l of this.progressListeners) l();
}
},
);
// Accept Common Server Requests - Return empty/null so servers don't
// stall. Good enough for a CLI; a real client would answer properly.
this.conn.onRequest("workspace/configuration", () => []);
this.conn.onRequest("client/registerCapability", () => null);
this.conn.onRequest("client/unregisterCapability", () => null);
this.conn.listen();
const rootUri = pathToUri(rootDir);
const params: InitializeParams = {
processId: process.pid,
rootUri,
workspaceFolders: [{ uri: rootUri, name: rootDir }],
capabilities: {
textDocument: {
hover: { contentFormat: ["markdown", "plaintext"] },
definition: { linkSupport: false },
references: {},
completion: { completionItem: { snippetSupport: false } },
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
publishDiagnostics: {},
synchronization: { didSave: true },
},
workspace: { workspaceFolders: true, configuration: true },
},
};
await this.conn.sendRequest("initialize", {
...params,
capabilities: {
...params.capabilities,
window: { workDoneProgress: true },
},
});
this.conn.sendNotification("initialized", {});
}
// Wait For Ready - Resolves when there are no outstanding progress
// tokens, or after `timeoutMs`. For servers that never send progress,
// this effectively just waits for `graceMs` of quiet.
async waitForReady(timeoutMs = 15000, graceMs = 300): Promise<void> {
const deadline = Date.now() + timeoutMs;
// Initial Grace - Give the server a moment to announce begin tokens.
await new Promise((r) => setTimeout(r, graceMs));
while (this.progressTokens.size > 0 && Date.now() < deadline) {
await new Promise<void>((resolve) => {
const onDone = () => {
clearTimeout(timer);
this.progressListeners.delete(onDone);
resolve();
};
const timer = setTimeout(onDone, Math.min(500, deadline - Date.now()));
this.progressListeners.add(onDone);
});
}
}
// Open Document - Reads the file from disk and sends didOpen. Most
// servers require this before they'll answer hover/definition/etc.
openDocument(filePath: string): string {
const uri = pathToUri(filePath);
const text = fs.readFileSync(filePath, "utf8");
this.conn.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId: this.server.languageId ?? this.server.match[0],
version: 1,
text,
},
});
return uri;
}
// Send Raw LSP Request - Passthrough used by the command dispatcher.
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
return this.conn.sendRequest(method, params) as Promise<R>;
}
// Wait For Diagnostics - Resolves on the first publish for `uri` or
// after `timeoutMs`. Returns whatever we have for that URI.
async waitForDiagnostics(
uri: string,
timeoutMs: number,
): Promise<PublishDiagnosticsParams> {
if (this.diagnostics.has(uri)) return this.diagnostics.get(uri)!;
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.diagListeners.delete(listener);
resolve(this.diagnostics.get(uri) ?? { uri, diagnostics: [] });
}, timeoutMs);
const listener = (p: PublishDiagnosticsParams) => {
if (p.uri !== uri) return;
clearTimeout(timer);
this.diagListeners.delete(listener);
resolve(p);
};
this.diagListeners.add(listener);
});
}
// Dispose - Best-effort shutdown; kills the process if it doesn't exit.
async dispose(): Promise<void> {
if (this.conn) {
try {
await this.conn.sendRequest("shutdown", undefined);
this.conn.sendNotification("exit");
} catch {
// Ignore - we're tearing down anyway.
}
this.conn.dispose();
}
if (this.proc && !this.proc.killed) {
this.proc.kill();
}
}
}
// Convenience - Build a client, find root, start, and open the file.
export async function startClientForFile(
server: ServerConfig,
filePath: string,
): Promise<{ client: LspClient; uri: string; rootDir: string }> {
const rootDir = findRoot(filePath, server.rootMarkers);
const client = new LspClient(server);
await client.start(rootDir);
const uri = client.openDocument(filePath);
// Wait For Workspace Load - gopls & friends reject requests with errors
// like "no views" until their initial load completes.
await client.waitForReady();
return { client, uri, rootDir };
}
export { uriToPath };

59
src/commands.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { LspClient } from "./client.ts";
import type { LspCommand } from "./types.ts";
// Command Dispatcher - Each entry takes the user-supplied raw LSP params
// (minus textDocument.uri, which we inject from the opened file) and
// forwards to the appropriate LSP method.
//
// To add a new command: extend the LspCommand union in types.ts and add a
// handler here.
type Handler = (
client: LspClient,
uri: string,
params: Record<string, unknown>,
) => Promise<unknown>;
// Inject textDocument.uri - Saves callers from repeating it; they just
// pass e.g. {"position": {"line": 10, "character": 4}}.
function withDoc(
uri: string,
params: Record<string, unknown>,
): Record<string, unknown> {
const existing = (params.textDocument as Record<string, unknown>) ?? {};
return { ...params, textDocument: { uri, ...existing } };
}
const handlers: Record<LspCommand, Handler> = {
hover: (c, uri, p) => c.sendRequest("textDocument/hover", withDoc(uri, p)),
definition: (c, uri, p) =>
c.sendRequest("textDocument/definition", withDoc(uri, p)),
references: (c, uri, p) => {
const merged = withDoc(uri, p);
if (!("context" in merged)) merged.context = { includeDeclaration: true };
return c.sendRequest("textDocument/references", merged);
},
completion: (c, uri, p) =>
c.sendRequest("textDocument/completion", withDoc(uri, p)),
documentSymbol: (c, uri, p) =>
c.sendRequest("textDocument/documentSymbol", withDoc(uri, p)),
// Diagnostics - Pseudo-command; diagnostics arrive as a notification, so
// we wait briefly for the first publish after didOpen.
diagnostics: async (c, uri) => c.waitForDiagnostics(uri, 1500),
};
export function isLspCommand(v: string): v is LspCommand {
return v in handlers;
}
export function listCommands(): LspCommand[] {
return Object.keys(handlers) as LspCommand[];
}
export function runCommand(
cmd: LspCommand,
client: LspClient,
uri: string,
params: Record<string, unknown>,
): Promise<unknown> {
return handlers[cmd](client, uri, params);
}

40
src/root.ts Normal file
View File

@@ -0,0 +1,40 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { servers } from "../server.ts";
import type { ServerConfig } from "./types.ts";
// Resolve File URI To Local Path
export function uriToPath(uri: string): string {
return fileURLToPath(uri);
}
// Resolve Local Path To File URI
export function pathToUri(p: string): string {
return pathToFileURL(path.resolve(p)).toString();
}
// Pick Server By File Extension - match[] entries are matched against the
// file's extension (no dot). First 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));
if (!hit) {
throw new Error(`No LSP server registered for extension ".${ext}"`);
}
return hit;
}
// Find Project Root By Walking Upward - stops at the first directory
// containing any rootMarker. Falls back to the file's directory.
export function findRoot(filePath: string, markers: string[]): string {
let dir = path.dirname(path.resolve(filePath));
const { root } = path.parse(dir);
while (true) {
for (const m of markers) {
if (fs.existsSync(path.join(dir, m))) return dir;
}
if (dir === root) return path.dirname(path.resolve(filePath));
dir = path.dirname(dir);
}
}

28
src/types.ts Normal file
View File

@@ -0,0 +1,28 @@
export interface ServerConfig {
// Stable identifier (useful for logs and future daemon cache keys).
id: string;
// File extensions (no dot) or LSP language ids this server handles.
match: string[];
// Executable to spawn.
command: string;
// Arguments passed to the executable.
args: string[];
// Files/directories whose presence marks the project root. Searched
// upward from the target file; first match wins.
rootMarkers: string[];
// LSP languageId sent in didOpen. Defaults to match[0] if omitted.
languageId?: string;
// TTL Planning - When we eventually add a daemon, servers will be kept
// alive per (id, rootUri) for this many ms of idleness. Not used yet.
idleTtlMs?: number;
}
// Supported high-level commands exposed via the CLI. Extend this union
// (and the dispatcher in src/commands.ts) to add more.
export type LspCommand =
| "hover"
| "definition"
| "references"
| "completion"
| "documentSymbol"
| "diagnostics";