initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# @pi/lsp
|
||||||
|
|
||||||
|
LSP extension for pi coding agent. Provides LSP tools that the LLM can use to query language servers, plus automatic diagnostics after edit/write operations.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### LSP Tools (callable by LLM)
|
||||||
|
|
||||||
|
- `lsp_hover` - Get hover documentation for a symbol
|
||||||
|
- `lsp_definition` - Find the definition of a symbol
|
||||||
|
- `lsp_references` - Find all references to a symbol
|
||||||
|
- `lsp_completion` - Get completion suggestions
|
||||||
|
- `lsp_documentSymbol` - Get the symbol outline of a file
|
||||||
|
- `lsp_diagnostics` - Get lint/type-check diagnostics
|
||||||
|
|
||||||
|
### Auto-Check
|
||||||
|
|
||||||
|
Automatically runs LSP diagnostics after `edit` or `write` tool calls. If issues are found, sends a message with the diagnostics to the LLM.
|
||||||
|
|
||||||
|
**Enable/disable:**
|
||||||
|
```bash
|
||||||
|
pi --lsp-auto-check=false # Disable auto-check
|
||||||
|
pi --lsp-auto-check=true # Enable (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Check Command
|
||||||
|
|
||||||
|
Run diagnostics manually on specific files:
|
||||||
|
```bash
|
||||||
|
/lsp-check main.go utils.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.pi/extensions/lsp
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Usage (for development/testing)
|
||||||
|
|
||||||
|
```
|
||||||
|
tsx ./cli.ts <file> <lsp_command> <req_data_json>
|
||||||
|
```
|
||||||
|
|
||||||
|
`req_data_json` is the raw LSP params for the command, minus
|
||||||
|
`textDocument.uri` (we inject that from `<file>`).
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
- `hover`
|
||||||
|
- `definition`
|
||||||
|
- `references`
|
||||||
|
- `completion`
|
||||||
|
- `documentSymbol`
|
||||||
|
- `diagnostics` (waits briefly for the first `publishDiagnostics`)
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Hover at line 224, col 23 (LSP is 0-indexed, so subtract 1)
|
||||||
|
npm run lsp -- backend/api/server.go hover \
|
||||||
|
'{"position":{"line":223,"character":22}}'
|
||||||
|
|
||||||
|
# Go to definition
|
||||||
|
npm run lsp -- backend/api/server.go definition \
|
||||||
|
'{"position":{"line":223,"character":22}}'
|
||||||
|
|
||||||
|
# Document symbols (no params needed)
|
||||||
|
npm run lsp -- backend/api/server.go documentSymbol '{}'
|
||||||
|
|
||||||
|
# Diagnostics
|
||||||
|
npm run lsp -- backend/api/server.go diagnostics '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `LSP_DEBUG=1` to forward server stderr.
|
||||||
|
|
||||||
|
## Adding A Server
|
||||||
|
|
||||||
|
Edit `server.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: "rust-analyzer",
|
||||||
|
match: ["rs"],
|
||||||
|
command: "rust-analyzer",
|
||||||
|
args: [],
|
||||||
|
rootMarkers: ["Cargo.toml"],
|
||||||
|
languageId: "rust",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding A Command
|
||||||
|
|
||||||
|
1. Add to the `LspCommand` union in `src/types.ts`.
|
||||||
|
2. Add a handler in `src/commands.ts`.
|
||||||
|
|
||||||
|
## Future
|
||||||
|
|
||||||
|
- **Daemon with TTL** - `ServerConfig.idleTtlMs` is reserved for a future
|
||||||
|
daemon that keeps language servers alive per `(server.id, rootUri)` to
|
||||||
|
avoid cold-start latency. Not implemented; the CLI is short-lived and
|
||||||
|
spawns fresh each invocation.
|
||||||
61
cli.ts
Executable file
61
cli.ts
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/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";
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
function usage(): never {
|
||||||
|
process.stderr.write(
|
||||||
|
`Usage: cli.ts <file> <lsp_command> <req_data_json>\n` +
|
||||||
|
`Commands: ${listCommands().join(", ")}\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();
|
||||||
|
|
||||||
|
if (!isLspCommand(cmdArg)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
484
index.ts
Normal file
484
index.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
// LSP Extension - Registers tools that let the LLM query language servers
|
||||||
|
// for hover, definition, references, completions, document symbols, and
|
||||||
|
// diagnostics. Each tool spawns a short-lived server, runs one request,
|
||||||
|
// and tears it down (same lifecycle as the CLI).
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { LspClient, uriToPath } from "./src/client.ts";
|
||||||
|
import { pickServer, findRoot } from "./src/root.ts";
|
||||||
|
|
||||||
|
// Format Hover - Turn an LSP hover response into readable text.
|
||||||
|
function formatHover(result: unknown): string {
|
||||||
|
if (!result || typeof result !== "object") return "(no hover info)";
|
||||||
|
const hover = result as { contents?: unknown };
|
||||||
|
if (!hover.contents) return "(empty)";
|
||||||
|
|
||||||
|
// MarkupContent
|
||||||
|
if (
|
||||||
|
"value" in hover.contents &&
|
||||||
|
typeof (hover.contents as any).value === "string"
|
||||||
|
) {
|
||||||
|
return (hover.contents as any).value;
|
||||||
|
}
|
||||||
|
// MarkedString | MarkedString[]
|
||||||
|
if (Array.isArray(hover.contents)) {
|
||||||
|
return hover.contents
|
||||||
|
.map((s: any) => (typeof s === "string" ? s : (s?.value ?? "")))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"value" in hover.contents &&
|
||||||
|
typeof (hover.contents as any).language === "string"
|
||||||
|
) {
|
||||||
|
const ms = hover.contents as any;
|
||||||
|
return `\`\`\`${ms.language}\n${ms.value}\n\`\`\``;
|
||||||
|
}
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format Definition - Turn definition locations into readable text.
|
||||||
|
function formatDefinition(result: unknown): string {
|
||||||
|
if (!result) return "(no definition found)";
|
||||||
|
const locations = Array.isArray(result) ? result : [result];
|
||||||
|
if (locations.length === 0) return "(no definition found)";
|
||||||
|
|
||||||
|
return locations
|
||||||
|
.map((loc: any, i: number) => {
|
||||||
|
const file = uriToPath(loc.uri);
|
||||||
|
const range = loc.range;
|
||||||
|
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format References - Turn reference locations into readable text.
|
||||||
|
function formatReferences(result: unknown): string {
|
||||||
|
if (!result || !Array.isArray(result)) return "(no references found)";
|
||||||
|
if (result.length === 0) return "(no references found)";
|
||||||
|
|
||||||
|
return result
|
||||||
|
.map((loc: any, i: number) => {
|
||||||
|
const file = uriToPath(loc.uri);
|
||||||
|
const range = loc.range;
|
||||||
|
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format Completions - Turn completion items into readable text.
|
||||||
|
function formatCompletions(result: unknown): string {
|
||||||
|
if (!result) return "(no completions)";
|
||||||
|
|
||||||
|
// Resolve to CompletionItem[] if it's a CompletionList
|
||||||
|
let items: any[];
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
items = result;
|
||||||
|
} else if ("items" in result && Array.isArray((result as any).items)) {
|
||||||
|
items = (result as any).items;
|
||||||
|
} else {
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return "(no completions)";
|
||||||
|
|
||||||
|
// Limit to top 30 to avoid flooding context
|
||||||
|
const limited = items.slice(0, 30);
|
||||||
|
const lines = limited.map((item: any) => {
|
||||||
|
let label = item.label;
|
||||||
|
if (item.kind) {
|
||||||
|
const kindNames: Record<number, string> = {
|
||||||
|
1: "text",
|
||||||
|
2: "method",
|
||||||
|
3: "function",
|
||||||
|
4: "constructor",
|
||||||
|
5: "field",
|
||||||
|
6: "variable",
|
||||||
|
7: "class",
|
||||||
|
8: "interface",
|
||||||
|
9: "module",
|
||||||
|
10: "property",
|
||||||
|
11: "unit",
|
||||||
|
12: "value",
|
||||||
|
13: "enum",
|
||||||
|
14: "keyword",
|
||||||
|
15: "snippet",
|
||||||
|
16: "color",
|
||||||
|
17: "file",
|
||||||
|
18: "reference",
|
||||||
|
19: "folder",
|
||||||
|
20: "enumMember",
|
||||||
|
21: "constant",
|
||||||
|
22: "struct",
|
||||||
|
23: "event",
|
||||||
|
24: "operator",
|
||||||
|
25: "typeParameter",
|
||||||
|
};
|
||||||
|
label += ` (${kindNames[item.kind] ?? item.kind})`;
|
||||||
|
}
|
||||||
|
if (item.detail) label += ` — ${item.detail}`;
|
||||||
|
return `• ${label}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length > 30) {
|
||||||
|
lines.push(`\n... and ${items.length - 30} more`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format Document Symbols - Turn symbols into a tree outline.
|
||||||
|
function formatDocumentSymbols(result: unknown): string {
|
||||||
|
if (!result || !Array.isArray(result)) return "(no symbols)";
|
||||||
|
const symbols = result as any[];
|
||||||
|
if (symbols.length === 0) return "(no symbols)";
|
||||||
|
|
||||||
|
const kindNames: Record<number, string> = {
|
||||||
|
1: "File",
|
||||||
|
2: "Module",
|
||||||
|
3: "Namespace",
|
||||||
|
4: "Package",
|
||||||
|
5: "Class",
|
||||||
|
6: "Method",
|
||||||
|
7: "Property",
|
||||||
|
8: "Field",
|
||||||
|
9: "Constructor",
|
||||||
|
10: "Enum",
|
||||||
|
11: "Interface",
|
||||||
|
12: "Function",
|
||||||
|
13: "Variable",
|
||||||
|
14: "Constant",
|
||||||
|
15: "String",
|
||||||
|
16: "Number",
|
||||||
|
17: "Boolean",
|
||||||
|
18: "Array",
|
||||||
|
19: "Object",
|
||||||
|
20: "Key",
|
||||||
|
21: "Null",
|
||||||
|
22: "EnumMember",
|
||||||
|
23: "Struct",
|
||||||
|
24: "Event",
|
||||||
|
25: "Operator",
|
||||||
|
26: "TypeParameter",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSymbol(sym: any, indent = 0): string {
|
||||||
|
const pad = " ".repeat(indent);
|
||||||
|
const kind = kindNames[sym.kind] ?? `kind:${sym.kind}`;
|
||||||
|
const line = sym.range?.start?.line ? sym.range.start.line + 1 : "?";
|
||||||
|
let text = `${pad}${sym.name} [${kind}] :${line}`;
|
||||||
|
if (sym.children && Array.isArray(sym.children)) {
|
||||||
|
for (const child of sym.children) {
|
||||||
|
text += "\n" + renderSymbol(child, indent + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbols.map((s) => renderSymbol(s)).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format Diagnostics - Turn diagnostic messages into readable text.
|
||||||
|
function formatDiagnostics(result: unknown): string {
|
||||||
|
if (!result || !("diagnostics" in result)) return "(no diagnostics)";
|
||||||
|
const diags = (result as any).diagnostics;
|
||||||
|
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
|
||||||
|
|
||||||
|
const severityNames: Record<number, string> = {
|
||||||
|
1: "Error",
|
||||||
|
2: "Warning",
|
||||||
|
3: "Info",
|
||||||
|
4: "Hint",
|
||||||
|
};
|
||||||
|
|
||||||
|
return diags
|
||||||
|
.map((d: any, i: number) => {
|
||||||
|
const sev = severityNames[d.severity] ?? `sev:${d.severity}`;
|
||||||
|
const range = d.range;
|
||||||
|
const line = range?.start?.line != null ? range.start.line + 1 : "?";
|
||||||
|
const col =
|
||||||
|
range?.start?.character != null ? range.start.character + 1 : "?";
|
||||||
|
return `${i + 1}. [${sev}] ${d.message} (line ${line}, col ${col})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run LSP Request - Spawn a server, open the file, run one request, dispose.
|
||||||
|
// Mirrors the CLI lifecycle: fresh server per request.
|
||||||
|
async function runLsp(
|
||||||
|
filePath: string,
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const server = pickServer(filePath);
|
||||||
|
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||||
|
const client = new LspClient(server);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.start(rootDir);
|
||||||
|
const uri = client.openDocument(filePath);
|
||||||
|
await client.waitForReady();
|
||||||
|
// Populate textDocument.uri if the params have a textDocument field
|
||||||
|
if (params.textDocument && typeof params.textDocument === "object") {
|
||||||
|
params.textDocument = { ...params.textDocument, uri };
|
||||||
|
}
|
||||||
|
return client.sendRequest(method, params);
|
||||||
|
} finally {
|
||||||
|
// Fire-and-forget shutdown; don't wait for graceful exit.
|
||||||
|
void client.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run LSP Diagnostics - Diagnostics arrive as a notification, so we use
|
||||||
|
// the dedicated waitForDiagnostics helper instead of sendRequest.
|
||||||
|
async function runDiagnostics(filePath: string): Promise<unknown> {
|
||||||
|
const server = pickServer(filePath);
|
||||||
|
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||||
|
const client = new LspClient(server);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.start(rootDir);
|
||||||
|
const uri = client.openDocument(filePath);
|
||||||
|
await client.waitForReady();
|
||||||
|
return client.waitForDiagnostics(uri, 1500);
|
||||||
|
} finally {
|
||||||
|
void client.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared Parameters Schema - All position-based tools accept file + optional
|
||||||
|
// position. The "file" field is required; position defaults to (0, 0).
|
||||||
|
const PositionSchema = Type.Object({
|
||||||
|
file: Type.String({ description: "Path to the file to query" }),
|
||||||
|
line: Type.Optional(
|
||||||
|
Type.Integer({ description: "0-indexed line number (default: 0)" }),
|
||||||
|
),
|
||||||
|
character: Type.Optional(
|
||||||
|
Type.Integer({ description: "0-indexed character offset (default: 0)" }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
// Hover - Get hover documentation for the symbol at a position.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp_hover",
|
||||||
|
label: "LSP Hover",
|
||||||
|
description: "Get hover documentation for a symbol at a position in a file",
|
||||||
|
promptSnippet: "Query LSP server for hover docs at a cursor position",
|
||||||
|
parameters: PositionSchema,
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const filePath = path.resolve(ctx.cwd, params.file);
|
||||||
|
const lspParams = {
|
||||||
|
textDocument: {},
|
||||||
|
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||||
|
};
|
||||||
|
const result = await runLsp(filePath, "textDocument/hover", lspParams);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatHover(result) }],
|
||||||
|
details: { raw: result },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Definition - Find the definition location of a symbol.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp_definition",
|
||||||
|
label: "LSP Definition",
|
||||||
|
description: "Find the definition of a symbol at a position in a file",
|
||||||
|
promptSnippet: "Query LSP server for symbol definition locations",
|
||||||
|
parameters: PositionSchema,
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const filePath = path.resolve(ctx.cwd, params.file);
|
||||||
|
const lspParams = {
|
||||||
|
textDocument: {},
|
||||||
|
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||||
|
};
|
||||||
|
const result = await runLsp(
|
||||||
|
filePath,
|
||||||
|
"textDocument/definition",
|
||||||
|
lspParams,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatDefinition(result) }],
|
||||||
|
details: { raw: result },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// References - Find all references to a symbol.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp_references",
|
||||||
|
label: "LSP References",
|
||||||
|
description: "Find all references to a symbol at a position in a file",
|
||||||
|
promptSnippet: "Query LSP server for all references to a symbol",
|
||||||
|
parameters: PositionSchema,
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const filePath = path.resolve(ctx.cwd, params.file);
|
||||||
|
const lspParams = {
|
||||||
|
textDocument: {},
|
||||||
|
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||||
|
context: { includeDeclaration: true },
|
||||||
|
};
|
||||||
|
const result = await runLsp(
|
||||||
|
filePath,
|
||||||
|
"textDocument/references",
|
||||||
|
lspParams,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatReferences(result) }],
|
||||||
|
details: { raw: result },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Completion - Get completion suggestions at a position.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp_completion",
|
||||||
|
label: "LSP Completion",
|
||||||
|
description: "Get completion suggestions at a position in a file",
|
||||||
|
promptSnippet: "Query LSP server for completion items at a cursor position",
|
||||||
|
parameters: PositionSchema,
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const filePath = path.resolve(ctx.cwd, params.file);
|
||||||
|
const lspParams = {
|
||||||
|
textDocument: {},
|
||||||
|
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||||
|
};
|
||||||
|
const result = await runLsp(
|
||||||
|
filePath,
|
||||||
|
"textDocument/completion",
|
||||||
|
lspParams,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatCompletions(result) }],
|
||||||
|
details: { raw: result },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document Symbol - Get the outline/symbol tree of a file.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp_documentSymbol",
|
||||||
|
label: "LSP Document Symbols",
|
||||||
|
description: "Get the symbol outline (classes, functions, etc.) of a file",
|
||||||
|
promptSnippet: "Query LSP server for document symbol outline",
|
||||||
|
parameters: Type.Object({
|
||||||
|
file: Type.String({ description: "Path to the file" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const filePath = path.resolve(ctx.cwd, params.file);
|
||||||
|
const result = await runLsp(filePath, "textDocument/documentSymbol", {
|
||||||
|
textDocument: {},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatDocumentSymbols(result) }],
|
||||||
|
details: { raw: result },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Diagnostics - Get lint/type-check diagnostics for a file.
|
||||||
|
pi.registerTool({
|
||||||
|
name: "lsp_diagnostics",
|
||||||
|
label: "LSP Diagnostics",
|
||||||
|
description:
|
||||||
|
"Get diagnostics (errors, warnings) for a file from its LSP server",
|
||||||
|
promptSnippet: "Query LSP server for file diagnostics (errors, warnings)",
|
||||||
|
parameters: Type.Object({
|
||||||
|
file: Type.String({ description: "Path to the file" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const filePath = path.resolve(ctx.cwd, params.file);
|
||||||
|
const result = await runDiagnostics(filePath);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatDiagnostics(result) }],
|
||||||
|
details: { raw: result },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-Check Flag - Enable automatic diagnostics after edit/write
|
||||||
|
pi.registerFlag("lsp-auto-check", {
|
||||||
|
description:
|
||||||
|
"Automatically run LSP diagnostics after edit/write operations",
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-Check After Edit/Write - Run diagnostics automatically
|
||||||
|
pi.on("tool_result", async (event, ctx) => {
|
||||||
|
// Check Enabled
|
||||||
|
if (!pi.getFlag("lsp-auto-check")) return;
|
||||||
|
|
||||||
|
// Edit & Write Only
|
||||||
|
if (!["edit", "write"].includes(event.toolName)) return;
|
||||||
|
|
||||||
|
// Skip Errors
|
||||||
|
if (event.isError) return;
|
||||||
|
|
||||||
|
// Resolve Filepath
|
||||||
|
const filePath = event.input?.path;
|
||||||
|
if (!filePath || typeof filePath !== "string") return;
|
||||||
|
const absolutePath = path.resolve(ctx.cwd, filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run LSP diagnostics
|
||||||
|
const result = await runDiagnostics(absolutePath);
|
||||||
|
const formatted = formatDiagnostics(result);
|
||||||
|
|
||||||
|
// Only send a message if there are actual diagnostics
|
||||||
|
if (formatted !== "(no diagnostics)") {
|
||||||
|
pi.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "lsp-auto-check",
|
||||||
|
content: `LSP found issues in ${filePath}:\n${formatted}`,
|
||||||
|
display: true,
|
||||||
|
details: { filePath, diagnostics: result },
|
||||||
|
},
|
||||||
|
{ deliverAs: "steer" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - don't interrupt the flow
|
||||||
|
// Only log if there's an actual error we care about
|
||||||
|
if (error && typeof error === "object" && "message" in error) {
|
||||||
|
const msg = (error as { message: string }).message;
|
||||||
|
if (!msg.includes("not found on PATH")) {
|
||||||
|
console.error("LSP auto-check failed:", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual Check Command - Run diagnostics on specific files
|
||||||
|
pi.registerCommand("lsp-check", {
|
||||||
|
description: "Run LSP diagnostics on one or more files",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const files = args ? args.split(/\s+/) : [];
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
ctx.ui.notify("Usage: /lsp-check <file1> [file2...]", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const absolutePath = path.resolve(ctx.cwd, file);
|
||||||
|
try {
|
||||||
|
const result = await runDiagnostics(absolutePath);
|
||||||
|
const formatted = formatDiagnostics(result);
|
||||||
|
|
||||||
|
if (formatted === "(no diagnostics)") {
|
||||||
|
ctx.ui.notify(`${file}: ✓ No issues`, "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(`${file}:\n${formatted}`, "warning");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const msg =
|
||||||
|
error && typeof error === "object" && "message" in error
|
||||||
|
? (error as { message: string }).message
|
||||||
|
: "Unknown error";
|
||||||
|
ctx.ui.notify(`${file}: Failed to check - ${msg}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
631
package-lock.json
generated
Normal file
631
package-lock.json
generated
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
{
|
||||||
|
"name": "@pi/lsp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@pi/lsp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
|
"vscode-languageserver-protocol": "^3.17.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pi-lsp": "cli.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
|
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
|
"@esbuild/android-arm": "0.27.7",
|
||||||
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
|
"@esbuild/android-x64": "0.27.7",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||||
|
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/vscode-jsonrpc": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vscode-languageserver-protocol": {
|
||||||
|
"version": "3.17.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||||
|
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vscode-jsonrpc": "8.2.0",
|
||||||
|
"vscode-languageserver-types": "3.17.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vscode-languageserver-types": {
|
||||||
|
"version": "3.17.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||||
|
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@pi/lsp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "LSP tools for pi: hover, definition, references, completions, symbols, diagnostics.",
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"pi-lsp": "./cli.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lsp": "tsx ./cli.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
|
"vscode-languageserver-protocol": "^3.17.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
server.ts
Normal file
33
server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Server Registry - Maps a language id (or file extension) to an LSP server
|
||||||
|
// launcher plus the root markers that identify a project boundary.
|
||||||
|
//
|
||||||
|
// Add new servers here. `match` is a list of file extensions (no dot) OR
|
||||||
|
// language ids; either matches.
|
||||||
|
import type { ServerConfig } from "./src/types.ts";
|
||||||
|
|
||||||
|
export const servers: ServerConfig[] = [
|
||||||
|
{
|
||||||
|
id: "gopls",
|
||||||
|
match: ["go"],
|
||||||
|
command: "gopls",
|
||||||
|
args: ["-remote=auto"],
|
||||||
|
rootMarkers: ["go.work", "go.mod"],
|
||||||
|
languageId: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "typescript-language-server",
|
||||||
|
match: ["ts", "tsx", "js", "jsx", "mts", "cts"],
|
||||||
|
command: "typescript-language-server",
|
||||||
|
args: ["--stdio"],
|
||||||
|
rootMarkers: ["pnpm-workspace.yaml", "tsconfig.json", "package.json"],
|
||||||
|
languageId: "typescript",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pyright",
|
||||||
|
match: ["py"],
|
||||||
|
command: "pyright-langserver",
|
||||||
|
args: ["--stdio"],
|
||||||
|
rootMarkers: ["pyproject.toml", "setup.py", "setup.cfg"],
|
||||||
|
languageId: "python",
|
||||||
|
},
|
||||||
|
];
|
||||||
258
src/client.ts
Normal file
258
src/client.ts
Normal 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
59
src/commands.ts
Normal 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
40
src/root.ts
Normal 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
28
src/types.ts
Normal 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";
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["cli.ts", "server.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user