@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 +58 -0
- package/capabilities.ts +54 -0
- package/client.ts +397 -0
- package/config.ts +99 -0
- package/defaults.json +40 -0
- package/diagnostic-summary.ts +69 -0
- package/diagnostics.ts +93 -0
- package/format.ts +190 -0
- package/guidance.ts +140 -0
- package/lsp.ts +375 -0
- package/manager.ts +396 -0
- package/overrides.ts +95 -0
- package/package.json +36 -0
- package/recent-paths.ts +126 -0
- package/runtime-state.ts +113 -0
- package/summary.ts +118 -0
- package/tool-actions.ts +211 -0
- package/transport.ts +188 -0
- package/tsconfig.json +5 -0
- package/types.ts +286 -0
- package/ui.ts +303 -0
- package/utils.ts +139 -0
package/runtime-state.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Runtime LSP guidance state — tracks qualifying source interactions and
|
|
2
|
+
// computes stateful pre-turn guidance so runtime guidance stays dormant
|
|
3
|
+
// until the session actually touches supported source files.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
buildRuntimeLspGuidance,
|
|
9
|
+
computeTrackedDiagnosticsSummary,
|
|
10
|
+
type RuntimeGuidanceInput,
|
|
11
|
+
} from "./guidance.ts";
|
|
12
|
+
import type { LspManager } from "./manager.ts";
|
|
13
|
+
import { getRawFilePathFromToolEvent } from "./recent-paths.ts";
|
|
14
|
+
import { displayRelativeFilePath } from "./summary.ts";
|
|
15
|
+
|
|
16
|
+
export const MAX_TRACKED_SOURCE_PATHS = 8;
|
|
17
|
+
|
|
18
|
+
export interface LspRuntimeGuidanceState {
|
|
19
|
+
runtimeActive: boolean;
|
|
20
|
+
trackedSourcePaths: string[];
|
|
21
|
+
pendingActivation: boolean;
|
|
22
|
+
lastInjectedFingerprint: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createRuntimeGuidanceState(): LspRuntimeGuidanceState {
|
|
26
|
+
return {
|
|
27
|
+
runtimeActive: false,
|
|
28
|
+
trackedSourcePaths: [],
|
|
29
|
+
pendingActivation: false,
|
|
30
|
+
lastInjectedFingerprint: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resetRuntimeGuidanceState(state: LspRuntimeGuidanceState): void {
|
|
35
|
+
state.runtimeActive = false;
|
|
36
|
+
state.trackedSourcePaths = [];
|
|
37
|
+
state.pendingActivation = false;
|
|
38
|
+
state.lastInjectedFingerprint = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerQualifyingSourceInteraction(
|
|
42
|
+
state: LspRuntimeGuidanceState,
|
|
43
|
+
manager: LspManager,
|
|
44
|
+
toolName: string,
|
|
45
|
+
input: Record<string, unknown>,
|
|
46
|
+
): void {
|
|
47
|
+
const rawPath = getRawFilePathFromToolEvent(toolName, input);
|
|
48
|
+
if (!rawPath) return;
|
|
49
|
+
if (!manager.isSupportedSourceFile(rawPath)) return;
|
|
50
|
+
|
|
51
|
+
// displayRelativeFilePath is the same form diagnostics get keyed under, so
|
|
52
|
+
// the tracked-files list lines up with diagnostic relevance matching for
|
|
53
|
+
// both in-tree files (relative form) and out-of-tree absolute paths.
|
|
54
|
+
const trackedPath = displayRelativeFilePath(rawPath);
|
|
55
|
+
|
|
56
|
+
// pendingActivation is a one-shot signal: set only on the first qualifying
|
|
57
|
+
// interaction so the next turn can inject the "LSP ready" hint exactly once.
|
|
58
|
+
// Subsequent interactions keep tracking files but must not re-arm activation
|
|
59
|
+
// — the caller clears the flag after injecting.
|
|
60
|
+
const wasDormant = !state.runtimeActive;
|
|
61
|
+
state.runtimeActive = true;
|
|
62
|
+
|
|
63
|
+
if (wasDormant) {
|
|
64
|
+
state.pendingActivation = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
state.trackedSourcePaths = [
|
|
68
|
+
trackedPath,
|
|
69
|
+
...state.trackedSourcePaths.filter((entry) => entry !== trackedPath),
|
|
70
|
+
].slice(0, MAX_TRACKED_SOURCE_PATHS);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drop tracked source paths whose underlying file is gone (deleted/renamed).
|
|
75
|
+
* Without this, `pruneMissingFiles()` cleans the live LSP clients but the
|
|
76
|
+
* runtime guidance would keep advertising the stale path on subsequent turns,
|
|
77
|
+
* and the session couldn't return to a dormant state until other interactions
|
|
78
|
+
* evicted the entry. Tracked paths are in `displayRelativeFilePath` form so
|
|
79
|
+
* `path.resolve` transparently handles both in-tree relative and out-of-tree
|
|
80
|
+
* absolute entries.
|
|
81
|
+
*/
|
|
82
|
+
export function pruneMissingTrackedPaths(state: LspRuntimeGuidanceState): void {
|
|
83
|
+
if (state.trackedSourcePaths.length === 0) return;
|
|
84
|
+
const surviving = state.trackedSourcePaths.filter((entry) => existsSync(path.resolve(entry)));
|
|
85
|
+
if (surviving.length === state.trackedSourcePaths.length) return;
|
|
86
|
+
state.trackedSourcePaths = surviving;
|
|
87
|
+
if (surviving.length === 0) {
|
|
88
|
+
state.runtimeActive = false;
|
|
89
|
+
state.pendingActivation = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function computePendingRuntimeGuidance(
|
|
94
|
+
state: LspRuntimeGuidanceState,
|
|
95
|
+
manager: LspManager,
|
|
96
|
+
inlineSeverity: number,
|
|
97
|
+
): { input: RuntimeGuidanceInput; content: string | null } | null {
|
|
98
|
+
if (!state.runtimeActive) return null;
|
|
99
|
+
|
|
100
|
+
const diagnosticsSummary = computeTrackedDiagnosticsSummary(
|
|
101
|
+
manager,
|
|
102
|
+
inlineSeverity,
|
|
103
|
+
state.trackedSourcePaths,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const input: RuntimeGuidanceInput = {
|
|
107
|
+
pendingActivation: state.pendingActivation,
|
|
108
|
+
diagnosticsSummary,
|
|
109
|
+
trackedFiles: state.trackedSourcePaths,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return { input, content: buildRuntimeLspGuidance(input) };
|
|
113
|
+
}
|
package/summary.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { ActiveCoverageSummaryEntry, OutstandingDiagnosticSummaryEntry } from "./manager.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Display form for a file path used both for human-readable LSP output and as
|
|
6
|
+
* the diagnostic key. In-tree paths return the project-relative form; out-of-
|
|
7
|
+
* tree paths preserve the absolute path so files in sibling worktrees or
|
|
8
|
+
* monorepo packages don't collapse to a basename — that collapse used to make
|
|
9
|
+
* unrelated files with the same name appear interchangeable in relevance
|
|
10
|
+
* matching, and it broke diagnostic correlation for tracked external paths.
|
|
11
|
+
*/
|
|
12
|
+
export function displayRelativeFilePath(filePath: string): string {
|
|
13
|
+
const absolutePath = path.resolve(filePath);
|
|
14
|
+
const relativePath = path.relative(process.cwd(), absolutePath);
|
|
15
|
+
if (relativePath === "") return path.basename(absolutePath);
|
|
16
|
+
if (relativePath.startsWith(`..${path.sep}`) || relativePath === "..") {
|
|
17
|
+
return absolutePath;
|
|
18
|
+
}
|
|
19
|
+
return relativePath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatCoverageSummaryText(
|
|
23
|
+
entries: ActiveCoverageSummaryEntry[],
|
|
24
|
+
maxServers: number,
|
|
25
|
+
maxFiles: number,
|
|
26
|
+
): string | null {
|
|
27
|
+
if (entries.length === 0) return null;
|
|
28
|
+
|
|
29
|
+
const visible = entries.slice(0, maxServers);
|
|
30
|
+
const parts = visible.map(
|
|
31
|
+
(entry) => `${entry.name} (${formatOpenFiles(entry.openFiles, maxFiles)})`,
|
|
32
|
+
);
|
|
33
|
+
const remaining = entries.length - visible.length;
|
|
34
|
+
const suffix =
|
|
35
|
+
remaining > 0 ? `; +${remaining} more ${remaining === 1 ? "server" : "servers"}` : "";
|
|
36
|
+
|
|
37
|
+
return `Active LSP coverage: ${parts.join("; ")}${suffix}.`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatOutstandingDiagnosticsSummaryText(
|
|
41
|
+
entries: OutstandingDiagnosticSummaryEntry[],
|
|
42
|
+
maxFiles: number,
|
|
43
|
+
): string | null {
|
|
44
|
+
if (entries.length === 0) return null;
|
|
45
|
+
|
|
46
|
+
const visible = entries.slice(0, maxFiles);
|
|
47
|
+
const parts = visible.map((entry) => `${entry.file} (${formatDiagnosticCounts(entry)})`);
|
|
48
|
+
const remaining = entries.length - visible.length;
|
|
49
|
+
const suffix = remaining > 0 ? `; +${remaining} more ${remaining === 1 ? "file" : "files"}` : "";
|
|
50
|
+
|
|
51
|
+
return `Outstanding LSP diagnostics: ${parts.join("; ")}${suffix}.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeRelevantPaths(relevantPaths: string[]): string[] {
|
|
55
|
+
return Array.from(new Set(relevantPaths.map(normalizeRelevantPath).filter(Boolean)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Match a file against caller-supplied relevance hints. Hints come from prompt
|
|
60
|
+
* tokens and recent tool paths, so they're heterogeneous: full relative paths,
|
|
61
|
+
* directory names, or bare filenames. Matching modes:
|
|
62
|
+
* - exact path match
|
|
63
|
+
* - candidate contains "/": treat as a directory prefix (`lsp/foo` ⊂ `lsp/foo/...`)
|
|
64
|
+
* - candidate has no "/" and no ".": treat as a directory name anywhere in the path
|
|
65
|
+
* - otherwise: treat as a filename and match the basename
|
|
66
|
+
*/
|
|
67
|
+
export function isPathRelevant(filePath: string, relevantPaths: string[]): boolean {
|
|
68
|
+
const normalizedFilePath = normalizeRelevantPath(filePath);
|
|
69
|
+
if (shouldIgnoreLspPath(normalizedFilePath)) return false;
|
|
70
|
+
|
|
71
|
+
return relevantPaths.some((candidate) => {
|
|
72
|
+
if (normalizedFilePath === candidate) return true;
|
|
73
|
+
if (candidate.includes("/")) return normalizedFilePath.startsWith(`${candidate}/`);
|
|
74
|
+
if (!candidate.includes(".")) {
|
|
75
|
+
return (
|
|
76
|
+
normalizedFilePath.startsWith(`${candidate}/`) ||
|
|
77
|
+
normalizedFilePath.includes(`/${candidate}/`)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return path.basename(normalizedFilePath) === candidate;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function shouldIgnoreLspPath(filePath: string): boolean {
|
|
85
|
+
const normalized = normalizeRelevantPath(filePath);
|
|
86
|
+
return (
|
|
87
|
+
normalized === "node_modules" ||
|
|
88
|
+
normalized.startsWith("node_modules/") ||
|
|
89
|
+
normalized.includes("/node_modules/") ||
|
|
90
|
+
normalized === ".pnpm" ||
|
|
91
|
+
normalized.startsWith(".pnpm/") ||
|
|
92
|
+
normalized.includes("/.pnpm/")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeRelevantPath(filePath: string): string {
|
|
97
|
+
return filePath.replaceAll("\\", "/").replace(/\/$/, "").trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatOpenFiles(openFiles: string[], maxFiles: number): string {
|
|
101
|
+
const visible = openFiles.slice(0, maxFiles);
|
|
102
|
+
const remaining = openFiles.length - visible.length;
|
|
103
|
+
const suffix = remaining > 0 ? `, +${remaining} more` : "";
|
|
104
|
+
return `${pluralize(openFiles.length, "open file")}: ${visible.join(", ")}${suffix}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatDiagnosticCounts(entry: OutstandingDiagnosticSummaryEntry): string {
|
|
108
|
+
const counts: string[] = [];
|
|
109
|
+
if (entry.errors > 0) counts.push(pluralize(entry.errors, "error"));
|
|
110
|
+
if (entry.warnings > 0) counts.push(pluralize(entry.warnings, "warning"));
|
|
111
|
+
if (entry.information > 0) counts.push(pluralize(entry.information, "info"));
|
|
112
|
+
if (entry.hints > 0) counts.push(pluralize(entry.hints, "hint"));
|
|
113
|
+
return counts.join(", ");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pluralize(count: number, word: string): string {
|
|
117
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
118
|
+
}
|
package/tool-actions.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// LSP tool action implementations — dispatches agent tool calls to LSP clients.
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { formatDiagnostics } from "./diagnostics.ts";
|
|
6
|
+
import {
|
|
7
|
+
formatCodeActions,
|
|
8
|
+
formatDocumentSymbols,
|
|
9
|
+
formatHover,
|
|
10
|
+
formatLocations,
|
|
11
|
+
formatSymbolInformation,
|
|
12
|
+
formatWorkspaceEdit,
|
|
13
|
+
normalizeLocations,
|
|
14
|
+
} from "./format.ts";
|
|
15
|
+
import type { LspManager } from "./manager.ts";
|
|
16
|
+
import type { DocumentSymbol, Range, SymbolInformation } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export type LspAction =
|
|
21
|
+
| "hover"
|
|
22
|
+
| "definition"
|
|
23
|
+
| "references"
|
|
24
|
+
| "diagnostics"
|
|
25
|
+
| "symbols"
|
|
26
|
+
| "rename"
|
|
27
|
+
| "code_actions";
|
|
28
|
+
|
|
29
|
+
export interface LspToolParams {
|
|
30
|
+
action: LspAction;
|
|
31
|
+
file?: string;
|
|
32
|
+
line?: number;
|
|
33
|
+
character?: number;
|
|
34
|
+
newName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Tool Description ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export const lspToolDescription = `Language Server Protocol tool — provides type-aware code intelligence.
|
|
40
|
+
|
|
41
|
+
Actions:
|
|
42
|
+
- hover: Get type info and docs at a position. Params: file, line, character
|
|
43
|
+
- definition: Go to definition of a symbol. Params: file, line, character
|
|
44
|
+
- references: Find all references to a symbol. Params: file, line, character
|
|
45
|
+
- diagnostics: Get type errors and warnings. Params: file (optional — omit for all files)
|
|
46
|
+
- symbols: List all symbols in a file. Params: file
|
|
47
|
+
- rename: Rename a symbol across the project. Params: file, line, character, newName
|
|
48
|
+
- code_actions: Get available fixes/refactors at a position. Params: file, line, character
|
|
49
|
+
|
|
50
|
+
Line and character are 1-based. File paths are relative to cwd.`;
|
|
51
|
+
|
|
52
|
+
// ── Action Dispatcher ─────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function executeAction(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
55
|
+
switch (params.action) {
|
|
56
|
+
case "hover":
|
|
57
|
+
return handleHover(manager, params);
|
|
58
|
+
case "definition":
|
|
59
|
+
return handleDefinition(manager, params);
|
|
60
|
+
case "references":
|
|
61
|
+
return handleReferences(manager, params);
|
|
62
|
+
case "diagnostics":
|
|
63
|
+
return handleDiagnostics(manager, params);
|
|
64
|
+
case "symbols":
|
|
65
|
+
return handleSymbols(manager, params);
|
|
66
|
+
case "rename":
|
|
67
|
+
return handleRename(manager, params);
|
|
68
|
+
case "code_actions":
|
|
69
|
+
return handleCodeActions(manager, params);
|
|
70
|
+
default:
|
|
71
|
+
return `Unknown action: ${params.action}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Action Handlers ───────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async function handleHover(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
78
|
+
const { file, line, character } = requireFilePosition(params);
|
|
79
|
+
const client = await manager.ensureFileOpen(file);
|
|
80
|
+
if (!client) return noServerMessage(file);
|
|
81
|
+
|
|
82
|
+
const hover = await client.hover(path.resolve(file), toZeroBased(line, character));
|
|
83
|
+
if (!hover) return "No hover information available at this position.";
|
|
84
|
+
return formatHover(hover);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleDefinition(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
88
|
+
const { file, line, character } = requireFilePosition(params);
|
|
89
|
+
const client = await manager.ensureFileOpen(file);
|
|
90
|
+
if (!client) return noServerMessage(file);
|
|
91
|
+
|
|
92
|
+
const result = await client.definition(path.resolve(file), toZeroBased(line, character));
|
|
93
|
+
if (!result) return "No definition found.";
|
|
94
|
+
|
|
95
|
+
const locations = normalizeLocations(result);
|
|
96
|
+
if (locations.length === 0) return "No definition found.";
|
|
97
|
+
|
|
98
|
+
return formatLocations("Definition", locations);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleReferences(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
102
|
+
const { file, line, character } = requireFilePosition(params);
|
|
103
|
+
const client = await manager.ensureFileOpen(file);
|
|
104
|
+
if (!client) return noServerMessage(file);
|
|
105
|
+
|
|
106
|
+
const locations = await client.references(path.resolve(file), toZeroBased(line, character));
|
|
107
|
+
if (!locations || locations.length === 0) return "No references found.";
|
|
108
|
+
|
|
109
|
+
return formatLocations("References", locations);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleDiagnostics(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
113
|
+
if (params.file) {
|
|
114
|
+
const resolvedPath = path.resolve(params.file);
|
|
115
|
+
const client = await manager.ensureFileOpen(params.file);
|
|
116
|
+
if (!client) return noServerMessage(params.file);
|
|
117
|
+
|
|
118
|
+
let content: string;
|
|
119
|
+
try {
|
|
120
|
+
content = fs.readFileSync(resolvedPath, "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
return `Error: cannot read file ${params.file}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diags = await client.syncAndWaitForDiagnostics(resolvedPath, content);
|
|
126
|
+
return formatDiagnostics(params.file, diags);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const summary = manager.getDiagnosticSummary();
|
|
130
|
+
if (summary.length === 0) return "No diagnostics across any files.";
|
|
131
|
+
|
|
132
|
+
const lines = ["## Diagnostics Summary\n"];
|
|
133
|
+
for (const s of summary) {
|
|
134
|
+
lines.push(`- **${s.file}**: ${s.errors} error(s), ${s.warnings} warning(s)`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleSymbols(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
140
|
+
if (!params.file) return "Error: 'file' parameter is required for symbols action.";
|
|
141
|
+
|
|
142
|
+
const client = await manager.ensureFileOpen(params.file);
|
|
143
|
+
if (!client) return noServerMessage(params.file);
|
|
144
|
+
|
|
145
|
+
const symbols = await client.documentSymbols(path.resolve(params.file));
|
|
146
|
+
if (!symbols || symbols.length === 0) return "No symbols found.";
|
|
147
|
+
|
|
148
|
+
if ("children" in symbols[0] || "selectionRange" in symbols[0]) {
|
|
149
|
+
return formatDocumentSymbols(symbols as DocumentSymbol[], 0);
|
|
150
|
+
}
|
|
151
|
+
return formatSymbolInformation(symbols as SymbolInformation[]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function handleRename(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
155
|
+
const { file, line, character } = requireFilePosition(params);
|
|
156
|
+
if (!params.newName) return "Error: 'newName' parameter is required for rename action.";
|
|
157
|
+
|
|
158
|
+
const client = await manager.ensureFileOpen(file);
|
|
159
|
+
if (!client) return noServerMessage(file);
|
|
160
|
+
|
|
161
|
+
const edit = await client.rename(
|
|
162
|
+
path.resolve(file),
|
|
163
|
+
toZeroBased(line, character),
|
|
164
|
+
params.newName,
|
|
165
|
+
);
|
|
166
|
+
if (!edit) return "Rename not available at this position.";
|
|
167
|
+
|
|
168
|
+
return formatWorkspaceEdit(edit);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleCodeActions(manager: LspManager, params: LspToolParams): Promise<string> {
|
|
172
|
+
const { file, line, character } = requireFilePosition(params);
|
|
173
|
+
const client = await manager.ensureFileOpen(file);
|
|
174
|
+
if (!client) return noServerMessage(file);
|
|
175
|
+
|
|
176
|
+
const pos = toZeroBased(line, character);
|
|
177
|
+
const range: Range = { start: pos, end: pos };
|
|
178
|
+
const diags = client.getDiagnostics(path.resolve(file));
|
|
179
|
+
|
|
180
|
+
const relevantDiags = diags.filter(
|
|
181
|
+
(d) => d.range.start.line <= pos.line && d.range.end.line >= pos.line,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const actions = await client.codeActions(path.resolve(file), range, {
|
|
185
|
+
diagnostics: relevantDiags,
|
|
186
|
+
});
|
|
187
|
+
if (!actions || actions.length === 0) return "No code actions available at this position.";
|
|
188
|
+
|
|
189
|
+
return formatCodeActions(actions);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Utility ───────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function requireFilePosition(params: LspToolParams): {
|
|
195
|
+
file: string;
|
|
196
|
+
line: number;
|
|
197
|
+
character: number;
|
|
198
|
+
} {
|
|
199
|
+
if (!params.file) throw new Error("'file' parameter is required.");
|
|
200
|
+
if (params.line === undefined) throw new Error("'line' parameter is required.");
|
|
201
|
+
if (params.character === undefined) throw new Error("'character' parameter is required.");
|
|
202
|
+
return { file: params.file, line: params.line, character: params.character };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toZeroBased(line: number, character: number): { line: number; character: number } {
|
|
206
|
+
return { line: line - 1, character: character - 1 };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function noServerMessage(file: string): string {
|
|
210
|
+
return `No LSP server available for this file type (${path.extname(file) || "unknown"})`;
|
|
211
|
+
}
|
package/transport.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// JSON-RPC 2.0 transport over stdio with Content-Length header framing.
|
|
2
|
+
|
|
3
|
+
import type { Readable, Writable } from "node:stream";
|
|
4
|
+
import type {
|
|
5
|
+
JsonRpcMessage,
|
|
6
|
+
JsonRpcNotification,
|
|
7
|
+
JsonRpcRequest,
|
|
8
|
+
JsonRpcResponse,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const CONTENT_LENGTH = "Content-Length: ";
|
|
12
|
+
const HEADER_DELIMITER = "\r\n\r\n";
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type NotificationHandler = (method: string, params: unknown) => void;
|
|
18
|
+
|
|
19
|
+
interface PendingRequest {
|
|
20
|
+
resolve: (result: unknown) => void;
|
|
21
|
+
reject: (error: Error) => void;
|
|
22
|
+
timer: ReturnType<typeof setTimeout>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── JsonRpcClient ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export class JsonRpcClient {
|
|
28
|
+
private nextId = 1;
|
|
29
|
+
private buffer = Buffer.alloc(0);
|
|
30
|
+
private pending = new Map<number, PendingRequest>();
|
|
31
|
+
private notificationHandler: NotificationHandler | null = null;
|
|
32
|
+
private closed = false;
|
|
33
|
+
private readonly timeoutMs: number;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly input: Readable,
|
|
37
|
+
private readonly output: Writable,
|
|
38
|
+
options?: { timeoutMs?: number },
|
|
39
|
+
) {
|
|
40
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
41
|
+
this.input.on("data", (chunk: Buffer) => this.onData(chunk));
|
|
42
|
+
this.input.on("end", () => this.onClose());
|
|
43
|
+
this.input.on("error", () => this.onClose());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Register a handler for server notifications (no id). */
|
|
47
|
+
onNotification(handler: NotificationHandler): void {
|
|
48
|
+
this.notificationHandler = handler;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Send a request and wait for the correlated response. */
|
|
52
|
+
sendRequest(method: string, params?: unknown): Promise<unknown> {
|
|
53
|
+
if (this.closed) {
|
|
54
|
+
return Promise.reject(new Error("JSON-RPC client is closed"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const id = this.nextId++;
|
|
58
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
this.pending.delete(id);
|
|
61
|
+
reject(new Error(`Request ${method} (id=${id}) timed out after ${this.timeoutMs}ms`));
|
|
62
|
+
}, this.timeoutMs);
|
|
63
|
+
|
|
64
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const msg: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
|
|
68
|
+
this.writeMessage(msg);
|
|
69
|
+
|
|
70
|
+
// Prevent unhandled rejection when dispose() rejects orphaned promises
|
|
71
|
+
promise.catch(() => {});
|
|
72
|
+
return promise;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Send a notification (no response expected). */
|
|
76
|
+
sendNotification(method: string, params?: unknown): void {
|
|
77
|
+
if (this.closed) return;
|
|
78
|
+
const msg: JsonRpcNotification = { jsonrpc: "2.0", method, params };
|
|
79
|
+
this.writeMessage(msg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Clean up all pending requests. */
|
|
83
|
+
dispose(): void {
|
|
84
|
+
this.closed = true;
|
|
85
|
+
for (const [id, p] of this.pending) {
|
|
86
|
+
clearTimeout(p.timer);
|
|
87
|
+
p.reject(new Error("JSON-RPC client disposed"));
|
|
88
|
+
this.pending.delete(id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Private ───────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
private writeMessage(msg: JsonRpcMessage): void {
|
|
95
|
+
const body = JSON.stringify(msg);
|
|
96
|
+
const contentLength = Buffer.byteLength(body, "utf-8");
|
|
97
|
+
const header = `${CONTENT_LENGTH}${contentLength}${HEADER_DELIMITER}`;
|
|
98
|
+
this.output.write(header + body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private onData(chunk: Buffer): void {
|
|
102
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
103
|
+
this.processBuffer();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private processBuffer(): void {
|
|
107
|
+
// eslint-disable-next-line no-constant-condition
|
|
108
|
+
while (true) {
|
|
109
|
+
// Look for header delimiter
|
|
110
|
+
const headerEnd = this.buffer.indexOf(HEADER_DELIMITER);
|
|
111
|
+
if (headerEnd === -1) return;
|
|
112
|
+
|
|
113
|
+
// Parse Content-Length from headers
|
|
114
|
+
const headerText = this.buffer.subarray(0, headerEnd).toString("utf-8");
|
|
115
|
+
const contentLength = parseContentLength(headerText);
|
|
116
|
+
if (contentLength === null) {
|
|
117
|
+
// Malformed header — skip past delimiter and try again
|
|
118
|
+
this.buffer = this.buffer.subarray(headerEnd + HEADER_DELIMITER.length);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if we have the full body
|
|
123
|
+
const bodyStart = headerEnd + HEADER_DELIMITER.length;
|
|
124
|
+
const messageEnd = bodyStart + contentLength;
|
|
125
|
+
if (this.buffer.length < messageEnd) {
|
|
126
|
+
return; // Need more data — partial message
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Extract and parse the body
|
|
130
|
+
const body = this.buffer.subarray(bodyStart, messageEnd).toString("utf-8");
|
|
131
|
+
this.buffer = this.buffer.subarray(messageEnd);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const msg = JSON.parse(body) as JsonRpcMessage;
|
|
135
|
+
this.handleMessage(msg);
|
|
136
|
+
} catch {
|
|
137
|
+
// Malformed JSON — skip
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private handleMessage(msg: JsonRpcMessage): void {
|
|
143
|
+
// Response (has id, has result or error)
|
|
144
|
+
if ("id" in msg && msg.id != null && ("result" in msg || "error" in msg)) {
|
|
145
|
+
const response = msg as JsonRpcResponse;
|
|
146
|
+
const pending = this.pending.get(response.id);
|
|
147
|
+
if (pending) {
|
|
148
|
+
this.pending.delete(response.id);
|
|
149
|
+
clearTimeout(pending.timer);
|
|
150
|
+
if (response.error) {
|
|
151
|
+
pending.reject(new Error(`LSP error ${response.error.code}: ${response.error.message}`));
|
|
152
|
+
} else {
|
|
153
|
+
pending.resolve(response.result);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Notification (no id)
|
|
160
|
+
if ("method" in msg && !("id" in msg)) {
|
|
161
|
+
const notification = msg as JsonRpcNotification;
|
|
162
|
+
this.notificationHandler?.(notification.method, notification.params);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Request from server (has id + method) — we don't handle server→client requests yet
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private onClose(): void {
|
|
169
|
+
this.closed = true;
|
|
170
|
+
for (const [id, p] of this.pending) {
|
|
171
|
+
clearTimeout(p.timer);
|
|
172
|
+
p.reject(new Error("JSON-RPC connection closed"));
|
|
173
|
+
this.pending.delete(id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function parseContentLength(header: string): number | null {
|
|
181
|
+
for (const line of header.split("\r\n")) {
|
|
182
|
+
if (line.startsWith(CONTENT_LENGTH)) {
|
|
183
|
+
const value = parseInt(line.slice(CONTENT_LENGTH.length), 10);
|
|
184
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|