Files
pi-lsp/cli.ts

167 lines
5.3 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 } 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:\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 {
// 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>;
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);
});