@mrclrchtr/supi-lsp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bash-guard.ts ADDED
@@ -0,0 +1,58 @@
1
+ const SEMANTIC_PROMPT_PATTERNS = [
2
+ /\bdefinition\b/i,
3
+ /\bfind(?: all)? references?\b/i,
4
+ /\breferences?\b/i,
5
+ /\busages?\b/i,
6
+ /\bsymbols?\b/i,
7
+ /\bhover\b/i,
8
+ /\brename\b/i,
9
+ /\bcode actions?\b/i,
10
+ /\bdiagnostics?\b/i,
11
+ /\btype errors?\b/i,
12
+ /\bwarning\b/i,
13
+ ];
14
+
15
+ const TEXT_SEARCH_COMMAND_PATTERNS = [
16
+ /\brg\b/,
17
+ /\bripgrep\b/,
18
+ /\bgrep\b/,
19
+ /\bgit\s+grep\b/,
20
+ /\back\b/,
21
+ /\bag\b/,
22
+ ];
23
+
24
+ /**
25
+ * Returns a redirect message when the agent is about to run a text-search
26
+ * command (rg/grep/etc.) for a clearly semantic question (definitions,
27
+ * references, symbols, diagnostics…) over files that already have active LSP
28
+ * coverage. In that combination the lsp tool is strictly more accurate, so we
29
+ * block the bash call and steer the agent to lsp. Returns null otherwise — we
30
+ * don't interfere with plain-text searches or with searches that fall outside
31
+ * the active LSP coverage.
32
+ */
33
+ export function shouldBlockSemanticBashSearch(
34
+ command: string,
35
+ prompt: string,
36
+ relevantPaths: string[],
37
+ hasRelevantCoverage: boolean,
38
+ ): string | null {
39
+ if (!hasRelevantCoverage) return null;
40
+ if (!isSemanticPrompt(prompt) || !isTextSearchCommand(command)) return null;
41
+
42
+ const visiblePaths = relevantPaths.slice(0, 2).join(", ");
43
+ const targetText = visiblePaths ? ` in ${visiblePaths}` : " in files with active LSP coverage";
44
+
45
+ return [
46
+ "Use the lsp tool instead of bash text search for semantic queries.",
47
+ `Active LSP coverage is available${targetText}.`,
48
+ "Prefer lsp for definitions, references, symbols, hover, rename planning, code actions, and diagnostics.",
49
+ ].join(" ");
50
+ }
51
+
52
+ export function isSemanticPrompt(prompt: string): boolean {
53
+ return SEMANTIC_PROMPT_PATTERNS.some((pattern) => pattern.test(prompt));
54
+ }
55
+
56
+ export function isTextSearchCommand(command: string): boolean {
57
+ return TEXT_SEARCH_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
58
+ }
@@ -0,0 +1,54 @@
1
+ // LSP client capabilities — declares what we support to servers.
2
+
3
+ import type { ClientCapabilities } from "./types.ts";
4
+
5
+ export const CLIENT_CAPABILITIES: ClientCapabilities = {
6
+ textDocument: {
7
+ synchronization: {
8
+ didSave: true,
9
+ dynamicRegistration: false,
10
+ },
11
+ hover: {
12
+ contentFormat: ["markdown", "plaintext"],
13
+ dynamicRegistration: false,
14
+ },
15
+ definition: {
16
+ dynamicRegistration: false,
17
+ linkSupport: true,
18
+ },
19
+ references: {
20
+ dynamicRegistration: false,
21
+ },
22
+ documentSymbol: {
23
+ dynamicRegistration: false,
24
+ hierarchicalDocumentSymbolSupport: true,
25
+ },
26
+ rename: {
27
+ dynamicRegistration: false,
28
+ prepareSupport: true,
29
+ },
30
+ codeAction: {
31
+ dynamicRegistration: false,
32
+ codeActionLiteralSupport: {
33
+ codeActionKind: {
34
+ valueSet: [
35
+ "quickfix",
36
+ "refactor",
37
+ "refactor.extract",
38
+ "refactor.inline",
39
+ "refactor.rewrite",
40
+ "source",
41
+ "source.organizeImports",
42
+ "source.fixAll",
43
+ ],
44
+ },
45
+ },
46
+ },
47
+ publishDiagnostics: {
48
+ relatedInformation: true,
49
+ },
50
+ },
51
+ workspace: {
52
+ workspaceFolders: false,
53
+ },
54
+ };
package/client.ts ADDED
@@ -0,0 +1,397 @@
1
+ // LSP Client — wraps a server process + JsonRpcClient.
2
+ // Handles initialize handshake, document sync, shutdown, and crash recovery.
3
+
4
+ import { type ChildProcess, spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { CLIENT_CAPABILITIES } from "./capabilities.ts";
7
+ import { JsonRpcClient } from "./transport.ts";
8
+ import type {
9
+ CodeAction,
10
+ CodeActionContext,
11
+ Diagnostic,
12
+ DocumentSymbol,
13
+ Hover,
14
+ InitializeResult,
15
+ Location,
16
+ LocationLink,
17
+ Position,
18
+ PublishDiagnosticsParams,
19
+ Range,
20
+ ServerCapabilities,
21
+ ServerConfig,
22
+ SymbolInformation,
23
+ TextDocumentIdentifier,
24
+ TextDocumentItem,
25
+ VersionedTextDocumentIdentifier,
26
+ WorkspaceEdit,
27
+ } from "./types.ts";
28
+ import { detectLanguageId, fileToUri, uriToFile } from "./utils.ts";
29
+
30
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
31
+ const DIAGNOSTIC_WAIT_MS = 3_000;
32
+
33
+ // ── Types ─────────────────────────────────────────────────────────────
34
+
35
+ export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
36
+
37
+ export interface DiagnosticEntry {
38
+ uri: string;
39
+ diagnostics: Diagnostic[];
40
+ }
41
+
42
+ // ── LspClient ─────────────────────────────────────────────────────────
43
+
44
+ export class LspClient {
45
+ readonly name: string;
46
+ readonly root: string;
47
+
48
+ private process: ChildProcess | null = null;
49
+ private rpc: JsonRpcClient | null = null;
50
+ private _status: ClientStatus = "initializing";
51
+ private capabilities: ServerCapabilities | null = null;
52
+
53
+ /** Open documents: uri → { version, languageId } */
54
+ private openDocs = new Map<string, { version: number; languageId: string }>();
55
+ /** Per-file diagnostics from the server */
56
+ private diagnosticStore = new Map<string, Diagnostic[]>();
57
+ /** Listeners waiting for diagnostics on a specific uri */
58
+ private diagnosticWaiters = new Map<string, Array<() => void>>();
59
+
60
+ constructor(
61
+ name: string,
62
+ private readonly config: ServerConfig,
63
+ root: string,
64
+ ) {
65
+ this.name = name;
66
+ this.root = root;
67
+ }
68
+
69
+ get status(): ClientStatus {
70
+ return this._status;
71
+ }
72
+
73
+ get openFiles(): string[] {
74
+ return Array.from(this.openDocs.keys()).map(uriToFile);
75
+ }
76
+
77
+ // ── Lifecycle ───────────────────────────────────────────────────────
78
+
79
+ /** Spawn the server process and perform the initialize handshake. */
80
+ async start(): Promise<void> {
81
+ const cmd = this.config.command;
82
+ const args = this.config.args ?? [];
83
+
84
+ try {
85
+ this.process = spawn(cmd, args, {
86
+ cwd: this.root,
87
+ stdio: ["pipe", "pipe", "pipe"],
88
+ env: { ...process.env },
89
+ });
90
+ } catch (err) {
91
+ this._status = "error";
92
+ throw new Error(`Failed to spawn ${cmd}: ${err}`, { cause: err });
93
+ }
94
+
95
+ if (!this.process.stdin || !this.process.stdout) {
96
+ this._status = "error";
97
+ this.process.kill();
98
+ throw new Error(`${cmd}: missing stdin/stdout`);
99
+ }
100
+
101
+ this.rpc = new JsonRpcClient(this.process.stdout, this.process.stdin);
102
+
103
+ // Handle notifications
104
+ this.rpc.onNotification((method, params) => {
105
+ if (method === "textDocument/publishDiagnostics") {
106
+ this.handlePublishDiagnostics(params as PublishDiagnosticsParams);
107
+ }
108
+ });
109
+
110
+ // Handle crashes
111
+ this.process.on("exit", (_code) => {
112
+ if (this._status !== "shutdown") {
113
+ this._status = "error";
114
+ }
115
+ this.rpc?.dispose();
116
+ });
117
+
118
+ this.process.on("error", (_err) => {
119
+ if (this._status !== "shutdown") {
120
+ this._status = "error";
121
+ }
122
+ });
123
+
124
+ // Suppress stderr to avoid noise in the agent
125
+ this.process.stderr?.on("data", () => {});
126
+
127
+ // Initialize handshake
128
+ try {
129
+ const result = (await this.rpc.sendRequest("initialize", {
130
+ processId: process.pid,
131
+ rootUri: fileToUri(this.root),
132
+ capabilities: CLIENT_CAPABILITIES,
133
+ initializationOptions: this.config.initializationOptions,
134
+ })) as InitializeResult;
135
+
136
+ this.capabilities = result.capabilities;
137
+ this.rpc.sendNotification("initialized", {});
138
+ this._status = "running";
139
+ } catch (err) {
140
+ this._status = "error";
141
+ this.process.kill();
142
+ throw new Error(`${this.name}: initialize failed: ${err}`, { cause: err });
143
+ }
144
+ }
145
+
146
+ /** Graceful shutdown: send shutdown → exit, kill after timeout. */
147
+ async shutdown(): Promise<void> {
148
+ if (this._status === "shutdown") return;
149
+ this._status = "shutdown";
150
+
151
+ if (!this.rpc || !this.process) return;
152
+
153
+ try {
154
+ // Send shutdown request with a timeout
155
+ await Promise.race([
156
+ this.rpc.sendRequest("shutdown"),
157
+ new Promise((_, reject) =>
158
+ setTimeout(() => reject(new Error("shutdown timeout")), SHUTDOWN_TIMEOUT_MS),
159
+ ),
160
+ ]);
161
+ this.rpc.sendNotification("exit");
162
+ } catch {
163
+ // Timeout or error — force kill
164
+ }
165
+
166
+ this.rpc.dispose();
167
+
168
+ // Wait briefly for clean exit, then force kill
169
+ if (this.process.exitCode === null) {
170
+ await new Promise<void>((resolve) => {
171
+ const timer = setTimeout(() => {
172
+ this.process?.kill("SIGTERM");
173
+ resolve();
174
+ }, 1_000);
175
+ this.process?.on("exit", () => {
176
+ clearTimeout(timer);
177
+ resolve();
178
+ });
179
+ });
180
+ }
181
+
182
+ this.openDocs.clear();
183
+ this.diagnosticStore.clear();
184
+ }
185
+
186
+ // ── Document Synchronization ────────────────────────────────────────
187
+
188
+ /** Open a document (or re-sync if already open). */
189
+ didOpen(filePath: string, content: string): void {
190
+ if (!this.rpc || this._status !== "running") return;
191
+
192
+ const uri = fileToUri(filePath);
193
+ const languageId = detectLanguageId(filePath);
194
+
195
+ if (this.openDocs.has(uri)) {
196
+ // Already open — send didChange instead
197
+ this.didChange(filePath, content);
198
+ return;
199
+ }
200
+
201
+ this.openDocs.set(uri, { version: 1, languageId });
202
+ this.rpc.sendNotification("textDocument/didOpen", {
203
+ textDocument: {
204
+ uri,
205
+ languageId,
206
+ version: 1,
207
+ text: content,
208
+ } satisfies TextDocumentItem,
209
+ });
210
+ }
211
+
212
+ /** Notify the server of a content change (full document sync). */
213
+ didChange(filePath: string, content: string): void {
214
+ if (!this.rpc || this._status !== "running") return;
215
+
216
+ const uri = fileToUri(filePath);
217
+ const doc = this.openDocs.get(uri);
218
+
219
+ if (!doc) {
220
+ // Not yet open — do a didOpen
221
+ this.didOpen(filePath, content);
222
+ return;
223
+ }
224
+
225
+ doc.version++;
226
+ this.rpc.sendNotification("textDocument/didChange", {
227
+ textDocument: { uri, version: doc.version } satisfies VersionedTextDocumentIdentifier,
228
+ contentChanges: [{ text: content }],
229
+ });
230
+ }
231
+
232
+ /** Close a document and clear any cached state for it. */
233
+ didClose(filePath: string): void {
234
+ const uri = fileToUri(filePath);
235
+ const wasOpen = this.openDocs.has(uri);
236
+
237
+ this.clearFileState(uri);
238
+
239
+ if (!wasOpen || !this.rpc || this._status !== "running") return;
240
+
241
+ this.rpc.sendNotification("textDocument/didClose", {
242
+ textDocument: { uri } satisfies TextDocumentIdentifier,
243
+ });
244
+ }
245
+
246
+ /** Prune missing files from open documents and cached diagnostics. */
247
+ pruneMissingFiles(): string[] {
248
+ const uris = new Set([...this.openDocs.keys(), ...this.diagnosticStore.keys()]);
249
+ const removedFiles: string[] = [];
250
+
251
+ for (const uri of uris) {
252
+ const filePath = uriToFile(uri);
253
+ if (existsSync(filePath)) continue;
254
+
255
+ const wasOpen = this.openDocs.has(uri);
256
+ this.clearFileState(uri);
257
+ removedFiles.push(filePath);
258
+
259
+ if (wasOpen && this.rpc && this._status === "running") {
260
+ this.rpc.sendNotification("textDocument/didClose", {
261
+ textDocument: { uri } satisfies TextDocumentIdentifier,
262
+ });
263
+ }
264
+ }
265
+
266
+ return removedFiles;
267
+ }
268
+
269
+ // ── Diagnostics ─────────────────────────────────────────────────────
270
+
271
+ /** Get stored diagnostics for a file. */
272
+ getDiagnostics(filePath: string): Diagnostic[] {
273
+ return this.diagnosticStore.get(fileToUri(filePath)) ?? [];
274
+ }
275
+
276
+ /** Get all stored diagnostics across all files. */
277
+ getAllDiagnostics(): DiagnosticEntry[] {
278
+ const result: DiagnosticEntry[] = [];
279
+ for (const [uri, diagnostics] of this.diagnosticStore) {
280
+ if (diagnostics.length > 0) {
281
+ result.push({ uri, diagnostics });
282
+ }
283
+ }
284
+ return result;
285
+ }
286
+
287
+ /**
288
+ * Sync a file and wait for diagnostics (up to timeout).
289
+ * Returns diagnostics for the file.
290
+ */
291
+ async syncAndWaitForDiagnostics(filePath: string, content: string): Promise<Diagnostic[]> {
292
+ const uri = fileToUri(filePath);
293
+
294
+ // Sync the content
295
+ this.didChange(filePath, content);
296
+
297
+ // Wait for publishDiagnostics or timeout
298
+ await new Promise<void>((resolve) => {
299
+ const timer = setTimeout(resolve, DIAGNOSTIC_WAIT_MS);
300
+ const waiters = this.diagnosticWaiters.get(uri) ?? [];
301
+ waiters.push(() => {
302
+ clearTimeout(timer);
303
+ resolve();
304
+ });
305
+ this.diagnosticWaiters.set(uri, waiters);
306
+ });
307
+
308
+ return this.getDiagnostics(filePath);
309
+ }
310
+
311
+ // ── LSP Requests ───────────────────────────────────────────────────
312
+
313
+ async hover(filePath: string, position: Position): Promise<Hover | null> {
314
+ return this.request("textDocument/hover", {
315
+ textDocument: { uri: fileToUri(filePath) },
316
+ position,
317
+ });
318
+ }
319
+
320
+ async definition(
321
+ filePath: string,
322
+ position: Position,
323
+ ): Promise<Location | Location[] | LocationLink[] | null> {
324
+ return this.request("textDocument/definition", {
325
+ textDocument: { uri: fileToUri(filePath) },
326
+ position,
327
+ });
328
+ }
329
+
330
+ async references(filePath: string, position: Position): Promise<Location[] | null> {
331
+ return this.request("textDocument/references", {
332
+ textDocument: { uri: fileToUri(filePath) },
333
+ position,
334
+ context: { includeDeclaration: true },
335
+ });
336
+ }
337
+
338
+ async documentSymbols(filePath: string): Promise<DocumentSymbol[] | SymbolInformation[] | null> {
339
+ return this.request("textDocument/documentSymbol", {
340
+ textDocument: { uri: fileToUri(filePath) },
341
+ });
342
+ }
343
+
344
+ async rename(
345
+ filePath: string,
346
+ position: Position,
347
+ newName: string,
348
+ ): Promise<WorkspaceEdit | null> {
349
+ return this.request("textDocument/rename", {
350
+ textDocument: { uri: fileToUri(filePath) },
351
+ position,
352
+ newName,
353
+ });
354
+ }
355
+
356
+ async codeActions(
357
+ filePath: string,
358
+ range: Range,
359
+ context: CodeActionContext,
360
+ ): Promise<CodeAction[] | null> {
361
+ return this.request("textDocument/codeAction", {
362
+ textDocument: { uri: fileToUri(filePath) },
363
+ range,
364
+ context,
365
+ });
366
+ }
367
+
368
+ // ── Private ─────────────────────────────────────────────────────────
369
+
370
+ private async request<T>(method: string, params: unknown): Promise<T | null> {
371
+ if (!this.rpc || this._status !== "running") return null;
372
+ try {
373
+ return (await this.rpc.sendRequest(method, params)) as T;
374
+ } catch {
375
+ return null;
376
+ }
377
+ }
378
+
379
+ private handlePublishDiagnostics(params: PublishDiagnosticsParams): void {
380
+ this.diagnosticStore.set(params.uri, params.diagnostics);
381
+ this.releaseDiagnosticWaiters(params.uri);
382
+ }
383
+
384
+ private clearFileState(uri: string): void {
385
+ this.openDocs.delete(uri);
386
+ this.diagnosticStore.delete(uri);
387
+ this.releaseDiagnosticWaiters(uri);
388
+ }
389
+
390
+ private releaseDiagnosticWaiters(uri: string): void {
391
+ const waiters = this.diagnosticWaiters.get(uri);
392
+ if (!waiters) return;
393
+
394
+ this.diagnosticWaiters.delete(uri);
395
+ for (const waiter of waiters) waiter();
396
+ }
397
+ }
package/config.ts ADDED
@@ -0,0 +1,99 @@
1
+ // LSP server configuration — load defaults, merge with project overrides.
2
+
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import type { LspConfig, ServerConfig } from "./types.ts";
6
+
7
+ // Load defaults at module level — resolve relative to this file.
8
+ // pi loads extensions via jiti, which always provides __dirname.
9
+ const DEFAULTS: LspConfig = JSON.parse(
10
+ fs.readFileSync(path.join(__dirname, "defaults.json"), "utf-8"),
11
+ ) as LspConfig;
12
+
13
+ // ── Public API ────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Load LSP config: built-in defaults merged with optional `.pi-lsp.json`
17
+ * from the project root. Project config takes precedence.
18
+ */
19
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: straightforward merge logic
20
+ export function loadConfig(cwd: string): LspConfig {
21
+ const defaults = DEFAULTS;
22
+ const projectOverrides = loadProjectConfig(cwd);
23
+
24
+ // Start from defaults, merge project overrides if present
25
+ const merged: Record<string, ServerConfig> = { ...defaults.servers };
26
+
27
+ if (projectOverrides) {
28
+ for (const [name, override] of Object.entries(projectOverrides.servers)) {
29
+ if (override.enabled === false) {
30
+ delete merged[name];
31
+ continue;
32
+ }
33
+ if (merged[name]) {
34
+ merged[name] = { ...merged[name], ...override };
35
+ } else {
36
+ // New server from project config — must have all required fields
37
+ if (override.command && override.fileTypes && override.rootMarkers) {
38
+ merged[name] = override as ServerConfig;
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ // Apply PI_LSP_SERVERS filter (always, even without project config)
45
+ const allowList = getServerAllowList();
46
+ if (allowList) {
47
+ for (const name of Object.keys(merged)) {
48
+ if (!allowList.has(name)) {
49
+ delete merged[name];
50
+ }
51
+ }
52
+ }
53
+
54
+ return { servers: merged };
55
+ }
56
+
57
+ /**
58
+ * Find which server config handles a given file extension.
59
+ * Returns [serverName, config] or null.
60
+ */
61
+ export function getServerForFile(
62
+ config: LspConfig,
63
+ filePath: string,
64
+ ): [string, ServerConfig] | null {
65
+ const ext = path.extname(filePath).slice(1).toLowerCase();
66
+ if (!ext) return null;
67
+
68
+ for (const [name, server] of Object.entries(config.servers)) {
69
+ if (server.fileTypes.includes(ext)) {
70
+ return [name, server];
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ // ── Private ───────────────────────────────────────────────────────────
77
+
78
+ function loadProjectConfig(cwd: string): LspConfig | null {
79
+ const jsonPath = path.join(cwd, ".pi-lsp.json");
80
+
81
+ if (fs.existsSync(jsonPath)) {
82
+ try {
83
+ const content = fs.readFileSync(jsonPath, "utf-8");
84
+ return JSON.parse(content) as LspConfig;
85
+ } catch {}
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ function getServerAllowList(): Set<string> | null {
92
+ const env = process.env.PI_LSP_SERVERS;
93
+ if (!env) return null;
94
+ const names = env
95
+ .split(",")
96
+ .map((s) => s.trim())
97
+ .filter(Boolean);
98
+ return names.length > 0 ? new Set(names) : null;
99
+ }
package/defaults.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "servers": {
3
+ "typescript-language-server": {
4
+ "command": "typescript-language-server",
5
+ "args": ["--stdio"],
6
+ "fileTypes": ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"],
7
+ "rootMarkers": ["tsconfig.json", "jsconfig.json", "package.json"]
8
+ },
9
+ "pyright": {
10
+ "command": "pyright-langserver",
11
+ "args": ["--stdio"],
12
+ "fileTypes": ["py", "pyi"],
13
+ "rootMarkers": [
14
+ "pyproject.toml",
15
+ "setup.py",
16
+ "setup.cfg",
17
+ "requirements.txt",
18
+ "pyrightconfig.json"
19
+ ]
20
+ },
21
+ "rust-analyzer": {
22
+ "command": "rust-analyzer",
23
+ "args": [],
24
+ "fileTypes": ["rs"],
25
+ "rootMarkers": ["Cargo.toml"]
26
+ },
27
+ "gopls": {
28
+ "command": "gopls",
29
+ "args": ["serve"],
30
+ "fileTypes": ["go", "mod"],
31
+ "rootMarkers": ["go.mod", "go.sum"]
32
+ },
33
+ "clangd": {
34
+ "command": "clangd",
35
+ "args": ["--background-index"],
36
+ "fileTypes": ["c", "h", "cpp", "hpp", "cc", "cxx", "hxx", "c++", "h++"],
37
+ "rootMarkers": ["compile_commands.json", "CMakeLists.txt", ".clangd", "Makefile"]
38
+ }
39
+ }
40
+ }