167 lines
5.3 KiB
TypeScript
Executable File
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);
|
|
});
|