feat(lsp): add background daemon for language servers
This commit is contained in:
151
cli.ts
151
cli.ts
@@ -3,28 +3,138 @@ import * as path from "node:path";
|
||||
import { startClientForFile } from "./src/client.ts";
|
||||
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
|
||||
import { pickServer } from "./src/root.ts";
|
||||
import {
|
||||
daemonDiagnostics,
|
||||
daemonRequest,
|
||||
daemonShutdown,
|
||||
daemonStatus,
|
||||
} from "./src/daemonClient.ts";
|
||||
import { socketPath } from "./src/daemonProtocol.ts";
|
||||
|
||||
// Usage
|
||||
function usage(): never {
|
||||
process.stderr.write(
|
||||
`Usage: cli.ts <file> <lsp_command> <req_data_json>\n` +
|
||||
`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);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , fileArg, cmdArg, jsonArg] = process.argv;
|
||||
if (!fileArg || !cmdArg || jsonArg === undefined) usage();
|
||||
// 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 {
|
||||
// Fire-And-Forget Shutdown - With `gopls -remote=auto` (and similar)
|
||||
// the spawned process is a thin client to a background daemon; a
|
||||
// graceful shutdown can hang the parent. Kick it off but don't wait.
|
||||
void client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// 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") {
|
||||
result = await daemonDiagnostics(filePath);
|
||||
} else if (cmdArg in methodMap) {
|
||||
// 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, 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>;
|
||||
@@ -37,25 +147,20 @@ async function main() {
|
||||
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 {
|
||||
// Fire-And-Forget Shutdown - With `gopls -remote=auto` (and similar)
|
||||
// the spawned process is a thin client to a background daemon; a
|
||||
// graceful shutdown can hang the parent. Kick it off but don't wait.
|
||||
void client.dispose();
|
||||
if (noDaemon) {
|
||||
await runInProcess(fileArg, cmdArg, params);
|
||||
} else {
|
||||
await runViaDaemon(fileArg, cmdArg, params);
|
||||
}
|
||||
// Hard Exit - Any lingering handles (LSP stdio, daemon stubs) would keep
|
||||
// the event loop alive. For a short-lived CLI we just exit.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${(err as Error).stack ?? err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user