Files
pi-lsp/cli.ts
Evan Reichard 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

174 lines
5.7 KiB
TypeScript
Executable File

#!/usr/bin/env -S npx tsx
import * as path from "node:path";
import { startClientForFile } from "./src/client.ts";
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
import { pickServer, isServerAvailable } from "./src/root.ts";
import { servers } from "./server.ts";
import {
daemonDiagnostics,
daemonRequest,
daemonShutdown,
daemonStatus,
} from "./src/daemonClient.ts";
import { socketPath } from "./src/daemonProtocol.ts";
// Usage
function usage(): never {
process.stderr.write(
`Usage:\n` +
` cli.ts <file> <lsp_command> <req_data_json> [--no-daemon]\n` +
` cli.ts daemon <status|stop>\n` +
`\n` +
`Commands: ${listCommands().join(", ")}\n` +
`\n` +
`By default requests are routed through the long-lived pi-lsp\n` +
`daemon (autospawned). Pass --no-daemon for a one-shot in-process\n` +
`client (useful for debugging).\n` +
`\n` +
`Example:\n` +
` cli.ts backend/api/server.go hover '{"position":{"line":223,"character":22}}'\n`,
);
process.exit(2);
}
// Daemon Subcommand - `daemon status` / `daemon stop`. Start is implicit:
// any LSP request autospawns the daemon if it isn't running.
async function daemonSubcommand(action: string | undefined): Promise<void> {
switch (action) {
case "status": {
const result = await daemonStatus();
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
return;
}
case "stop": {
try {
const result = await daemonShutdown();
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (err) {
// Already-stopped daemons surface as connect errors; treat as no-op.
process.stderr.write(
`daemon not running (${(err as Error).message})\n`,
);
}
return;
}
default:
process.stderr.write(
`Usage: cli.ts daemon <status|stop>\nSocket: ${socketPath()}\n`,
);
process.exit(2);
}
}
// Run In-Process - The legacy one-shot path: spawn the LSP server here,
// run the command, dispose, exit. Kept for `--no-daemon` debugging.
async function runInProcess(
fileArg: string,
cmdArg: string,
params: Record<string, unknown>,
): Promise<void> {
if (!isLspCommand(cmdArg)) {
process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
);
process.exit(2);
}
const filePath = path.resolve(fileArg);
const server = pickServer(filePath);
const { client, uri } = await startClientForFile(server, filePath);
try {
const result = await runCommand(cmdArg, client, uri, params);
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} finally {
// Hard Teardown - The no-daemon path is short-lived/debug-only. Avoid
// graceful JSON-RPC shutdown because the server stdio stream may already
// be closed, which can surface ERR_STREAM_DESTROYED during process exit.
void client.dispose({ graceful: false });
}
}
// Run Via Daemon - The default path. Hover/definition/references/completion/
// documentSymbol map to specific LSP method strings; diagnostics uses a
// dedicated op since it's notification-driven.
async function runViaDaemon(
fileArg: string,
cmdArg: string,
params: Record<string, unknown>,
): Promise<void> {
const methodMap: Record<string, string> = {
hover: "textDocument/hover",
definition: "textDocument/definition",
references: "textDocument/references",
completion: "textDocument/completion",
documentSymbol: "textDocument/documentSymbol",
};
const filePath = path.resolve(fileArg);
let result: unknown;
if (cmdArg === "diagnostics") {
// 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) {
const server = pickServer(filePath);
// References Default - Match commands.ts: include declaration unless
// caller explicitly overrode `context`.
if (cmdArg === "references" && !("context" in params)) {
params.context = { includeDeclaration: true };
}
result = await daemonRequest(filePath, server.id, methodMap[cmdArg], params);
} else {
process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
);
process.exit(2);
}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
}
async function main() {
const argv = process.argv.slice(2);
// Daemon Subcommand - First arg is the literal word "daemon".
if (argv[0] === "daemon") {
await daemonSubcommand(argv[1]);
return;
}
// Parse Flags - Pull out --no-daemon; positional args are unchanged.
const noDaemon = argv.includes("--no-daemon");
const positional = argv.filter((a) => a !== "--no-daemon");
const [fileArg, cmdArg, jsonArg] = positional;
if (!fileArg || !cmdArg || jsonArg === undefined) usage();
// Parse Request JSON
let params: Record<string, unknown>;
try {
params = jsonArg.trim() === "" ? {} : JSON.parse(jsonArg);
} catch (err) {
process.stderr.write(
`Invalid JSON for req_data_json: ${(err as Error).message}\n`,
);
process.exit(2);
}
if (noDaemon) {
await runInProcess(fileArg, cmdArg, params);
} else {
await runViaDaemon(fileArg, cmdArg, params);
}
}
main()
.then(() => {
// Hard Exit - Any lingering handles (sockets, LSP stdio, daemon stubs)
// would keep the event loop alive. For a short-lived CLI we just exit.
process.exit(0);
})
.catch((err) => {
process.stderr.write(`${(err as Error).stack ?? err}\n`);
process.exit(1);
});