pi-lsp-adapter 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/LICENSE +21 -0
- package/README.md +506 -0
- package/package.json +79 -0
- package/src/commands/registerCommands.ts +228 -0
- package/src/commands/status.ts +79 -0
- package/src/config/loadConfig.ts +377 -0
- package/src/config/paths.ts +58 -0
- package/src/config/trust.ts +85 -0
- package/src/detect/filetypes.ts +86 -0
- package/src/detect/root.ts +32 -0
- package/src/index.ts +132 -0
- package/src/install/installers.ts +513 -0
- package/src/install/lockfile.ts +159 -0
- package/src/install/manager.ts +177 -0
- package/src/install/version.ts +40 -0
- package/src/lsp/client.ts +465 -0
- package/src/lsp/processRegistry.ts +339 -0
- package/src/lsp/runtimeManager.ts +552 -0
- package/src/registry/builtin.ts +171 -0
- package/src/registry/schema.ts +185 -0
- package/src/resolve/languages.ts +96 -0
- package/src/resolve/resolveServer.ts +315 -0
- package/src/state.ts +20 -0
- package/src/statusLine.ts +21 -0
- package/src/tools/lspFormat.ts +594 -0
- package/src/tools/registerLspTools.ts +241 -0
- package/src/tools/registerLspWarmup.ts +25 -0
- package/src/tools/resultCache.ts +162 -0
- package/src/ui/lspPanel.ts +123 -0
- package/src/util/deepMerge.ts +38 -0
- package/src/util/errors.ts +22 -0
- package/src/util/hash.ts +10 -0
- package/src/util/helpers.ts +41 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getBinDir } from "../config/paths.js";
|
|
4
|
+
import type { Catalog, InstallMode, InstalledServerMetadata, ServerDefinition } from "../registry/schema.js";
|
|
5
|
+
import { ConfigError } from "../util/errors.js";
|
|
6
|
+
import {
|
|
7
|
+
getInstallBinName,
|
|
8
|
+
getServerPackageDir,
|
|
9
|
+
installServerBackend,
|
|
10
|
+
type InstallerOptions,
|
|
11
|
+
type InstallerResult,
|
|
12
|
+
} from "./installers.js";
|
|
13
|
+
import { readLockfile, removeServerLockfileEntry, writeServerLockfileEntry, type LockfileOptions } from "./lockfile.js";
|
|
14
|
+
|
|
15
|
+
export type InstallConfirmer = (request: InstallConfirmationRequest) => Promise<boolean>;
|
|
16
|
+
|
|
17
|
+
export interface InstallConfirmationRequest {
|
|
18
|
+
server: ServerDefinition;
|
|
19
|
+
command: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type BackendInstaller = (
|
|
23
|
+
server: ServerDefinition,
|
|
24
|
+
requestedVersion: string | undefined,
|
|
25
|
+
options: InstallerOptions,
|
|
26
|
+
) => Promise<InstallerResult>;
|
|
27
|
+
|
|
28
|
+
export interface LspInstallManagerOptions {
|
|
29
|
+
catalog: Catalog;
|
|
30
|
+
installMode?: InstallMode;
|
|
31
|
+
confirmer?: InstallConfirmer;
|
|
32
|
+
backendInstaller?: BackendInstaller;
|
|
33
|
+
installerOptions?: InstallerOptions;
|
|
34
|
+
lockfileOptions?: LockfileOptions;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EnsureInstalledResult =
|
|
38
|
+
| {
|
|
39
|
+
status: "installed";
|
|
40
|
+
serverId: string;
|
|
41
|
+
metadata: InstalledServerMetadata;
|
|
42
|
+
installedNow: boolean;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
status: "missing" | "declined";
|
|
46
|
+
serverId: string;
|
|
47
|
+
installCommand: string;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface InstallOperationResult {
|
|
52
|
+
serverId: string;
|
|
53
|
+
metadata: InstalledServerMetadata;
|
|
54
|
+
logPath: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UninstallOperationResult {
|
|
58
|
+
serverId: string;
|
|
59
|
+
removed: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class LspInstallManager {
|
|
63
|
+
private readonly catalog: Catalog;
|
|
64
|
+
private readonly installMode: InstallMode;
|
|
65
|
+
private readonly confirmer?: InstallConfirmer;
|
|
66
|
+
private readonly backendInstaller: BackendInstaller;
|
|
67
|
+
private readonly installerOptions: InstallerOptions;
|
|
68
|
+
private readonly lockfileOptions: LockfileOptions;
|
|
69
|
+
private installQueue: Promise<void> = Promise.resolve();
|
|
70
|
+
|
|
71
|
+
constructor(options: LspInstallManagerOptions) {
|
|
72
|
+
this.catalog = options.catalog;
|
|
73
|
+
this.installMode = options.installMode ?? "prompt";
|
|
74
|
+
this.confirmer = options.confirmer;
|
|
75
|
+
this.backendInstaller = options.backendInstaller ?? installServerBackend;
|
|
76
|
+
this.installerOptions = options.installerOptions ?? {};
|
|
77
|
+
this.lockfileOptions = options.lockfileOptions ?? {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async ensureInstalled(serverId: string, requestedVersion?: string): Promise<EnsureInstalledResult> {
|
|
81
|
+
const server = this.getServer(serverId);
|
|
82
|
+
const lockfile = await readLockfile(this.lockfileOptions);
|
|
83
|
+
const existing = lockfile.servers[serverId];
|
|
84
|
+
if (existing && requestedVersion === undefined) {
|
|
85
|
+
return { status: "installed", serverId, metadata: existing, installedNow: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const installCommand = formatInstallCommand(serverId, requestedVersion);
|
|
89
|
+
|
|
90
|
+
if (this.installMode === "off") {
|
|
91
|
+
return {
|
|
92
|
+
status: "missing",
|
|
93
|
+
serverId,
|
|
94
|
+
installCommand,
|
|
95
|
+
message: `${serverId} is not installed. Install mode is off; run ${installCommand} to install it explicitly.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (this.installMode === "prompt") {
|
|
100
|
+
if (!this.confirmer) {
|
|
101
|
+
return {
|
|
102
|
+
status: "missing",
|
|
103
|
+
serverId,
|
|
104
|
+
installCommand,
|
|
105
|
+
message: `${serverId} is not installed. Run ${installCommand} to install it.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const accepted = await this.confirmer({ server, command: installCommand });
|
|
110
|
+
if (!accepted) {
|
|
111
|
+
return {
|
|
112
|
+
status: "declined",
|
|
113
|
+
serverId,
|
|
114
|
+
installCommand,
|
|
115
|
+
message: `${serverId} is not installed; installation was declined.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const installed = await this.installServer(serverId, requestedVersion);
|
|
121
|
+
return { status: "installed", serverId, metadata: installed.metadata, installedNow: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async installServer(serverId: string, requestedVersion?: string): Promise<InstallOperationResult> {
|
|
125
|
+
return this.enqueueInstall(async () => {
|
|
126
|
+
const server = this.getServer(serverId);
|
|
127
|
+
const result = await this.backendInstaller(server, requestedVersion, this.installerOptions);
|
|
128
|
+
await writeServerLockfileEntry(serverId, result.metadata, this.lockfileOptions);
|
|
129
|
+
return { serverId, metadata: result.metadata, logPath: result.logPath };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async updateServer(serverId: string, requestedVersion?: string): Promise<InstallOperationResult> {
|
|
134
|
+
return this.installServer(serverId, requestedVersion);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async uninstallServer(serverId: string): Promise<UninstallOperationResult> {
|
|
138
|
+
return this.enqueueInstall(async () => {
|
|
139
|
+
const server = this.getServer(serverId);
|
|
140
|
+
const lockfile = await readLockfile(this.lockfileOptions);
|
|
141
|
+
const existed = lockfile.servers[serverId] !== undefined;
|
|
142
|
+
|
|
143
|
+
await removeServerLockfileEntry(serverId, this.lockfileOptions);
|
|
144
|
+
await rm(getServerPackageDir(serverId), { recursive: true, force: true });
|
|
145
|
+
await removeManagedBin(server);
|
|
146
|
+
|
|
147
|
+
return { serverId, removed: existed };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private getServer(serverId: string): ServerDefinition {
|
|
152
|
+
const server = this.catalog.servers[serverId];
|
|
153
|
+
if (!server) {
|
|
154
|
+
throw new ConfigError(`Unknown LSP server: ${serverId}`);
|
|
155
|
+
}
|
|
156
|
+
return server;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private enqueueInstall<T>(operation: () => Promise<T>): Promise<T> {
|
|
160
|
+
const run = this.installQueue.then(operation, operation);
|
|
161
|
+
this.installQueue = run.then(
|
|
162
|
+
() => undefined,
|
|
163
|
+
() => undefined,
|
|
164
|
+
);
|
|
165
|
+
return run;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatInstallCommand(serverId: string, requestedVersion?: string): string {
|
|
170
|
+
return `/lsp install ${requestedVersion ? `${serverId}@${requestedVersion}` : serverId}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function removeManagedBin(server: ServerDefinition): Promise<void> {
|
|
174
|
+
if (server.install.type === "system") return;
|
|
175
|
+
const binName = getInstallBinName(server);
|
|
176
|
+
await rm(join(getBinDir(), binName), { force: true });
|
|
177
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface ServerVersionSpec {
|
|
2
|
+
serverId: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function parseServerVersionSpec(input: string): ServerVersionSpec {
|
|
7
|
+
const trimmed = input.trim();
|
|
8
|
+
if (trimmed.length === 0) {
|
|
9
|
+
throw new Error("Expected a server id, for example pyright or pyright@1.1.405.");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const atIndex = trimmed.indexOf("@");
|
|
13
|
+
const serverId = atIndex === -1 ? trimmed : trimmed.slice(0, atIndex);
|
|
14
|
+
const version = atIndex === -1 ? undefined : trimmed.slice(atIndex + 1);
|
|
15
|
+
|
|
16
|
+
validateServerId(serverId);
|
|
17
|
+
|
|
18
|
+
if (version !== undefined) {
|
|
19
|
+
if (version.length === 0 || version.includes("@") || /\s/u.test(version)) {
|
|
20
|
+
throw new Error(`Invalid version in server spec: ${input}`);
|
|
21
|
+
}
|
|
22
|
+
return { serverId, version };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { serverId };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateServerId(serverId: string): void {
|
|
29
|
+
if (serverId.length === 0) {
|
|
30
|
+
throw new Error("Server id cannot be empty.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (/\s/u.test(serverId)) {
|
|
34
|
+
throw new Error(`Server id cannot contain whitespace: ${serverId}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (serverId.includes("/")) {
|
|
38
|
+
throw new Error(`Server id cannot contain '/': ${serverId}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { createWriteStream, WriteStream } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createMessageConnection,
|
|
6
|
+
StreamMessageReader,
|
|
7
|
+
StreamMessageWriter,
|
|
8
|
+
type Disposable,
|
|
9
|
+
} from "vscode-jsonrpc/node.js";
|
|
10
|
+
import {
|
|
11
|
+
DefinitionRequest,
|
|
12
|
+
DidChangeTextDocumentNotification,
|
|
13
|
+
DidOpenTextDocumentNotification,
|
|
14
|
+
ExitNotification,
|
|
15
|
+
HoverRequest,
|
|
16
|
+
InitializeRequest,
|
|
17
|
+
InitializedNotification,
|
|
18
|
+
PublishDiagnosticsNotification,
|
|
19
|
+
ReferencesRequest,
|
|
20
|
+
ShutdownRequest,
|
|
21
|
+
DocumentSymbolRequest,
|
|
22
|
+
WorkspaceSymbolRequest,
|
|
23
|
+
type Definition,
|
|
24
|
+
type Diagnostic,
|
|
25
|
+
type DocumentSymbol,
|
|
26
|
+
type Hover,
|
|
27
|
+
type InitializeParams,
|
|
28
|
+
type InitializeResult,
|
|
29
|
+
type Location,
|
|
30
|
+
type LocationLink,
|
|
31
|
+
type ReferenceParams,
|
|
32
|
+
type ServerCapabilities,
|
|
33
|
+
type SymbolInformation,
|
|
34
|
+
type SymbolKind,
|
|
35
|
+
type WorkspaceFolder,
|
|
36
|
+
type WorkspaceSymbol,
|
|
37
|
+
} from "vscode-languageserver-protocol";
|
|
38
|
+
import { URI } from "vscode-uri";
|
|
39
|
+
import { getLogsDir } from "../config/paths.js";
|
|
40
|
+
import type { JsonObject, JsonValue, ResolvedServerConfig } from "../registry/schema.js";
|
|
41
|
+
import type { LspProcessRegistry } from "./processRegistry.js";
|
|
42
|
+
|
|
43
|
+
export interface LspServerProcess {
|
|
44
|
+
pid?: number;
|
|
45
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
46
|
+
once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
47
|
+
once(event: "error", listener: (error: Error) => void): this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LspConnection {
|
|
51
|
+
listen(): void;
|
|
52
|
+
sendRequest<R>(method: string, params?: unknown): Promise<R>;
|
|
53
|
+
sendNotification(method: string, params?: unknown): Promise<void>;
|
|
54
|
+
onNotification(method: string, handler: (params: unknown) => void): Disposable;
|
|
55
|
+
onRequest(method: string, handler: (params: unknown) => unknown | Promise<unknown>): Disposable;
|
|
56
|
+
dispose(): void;
|
|
57
|
+
end(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type LspServerSpawner = (config: ResolvedServerConfig) => LspServerProcess;
|
|
61
|
+
export type LspConnectionFactory = (process: LspServerProcess) => LspConnection;
|
|
62
|
+
|
|
63
|
+
export interface LspClientOptions {
|
|
64
|
+
id: string;
|
|
65
|
+
ownerId: string;
|
|
66
|
+
config: ResolvedServerConfig;
|
|
67
|
+
processRegistry: LspProcessRegistry;
|
|
68
|
+
spawner?: LspServerSpawner;
|
|
69
|
+
connectionFactory?: LspConnectionFactory;
|
|
70
|
+
requestTimeoutMs?: number;
|
|
71
|
+
shutdownGraceMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LspClientDocument {
|
|
75
|
+
uri: string;
|
|
76
|
+
filePath: string;
|
|
77
|
+
languageId: string;
|
|
78
|
+
version: number;
|
|
79
|
+
text: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface LspDiagnosticsResult {
|
|
83
|
+
serverId: string;
|
|
84
|
+
rootDir: string;
|
|
85
|
+
filePath: string;
|
|
86
|
+
uri: string;
|
|
87
|
+
diagnostics: Diagnostic[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class LspClient {
|
|
91
|
+
readonly id: string;
|
|
92
|
+
readonly serverId: string;
|
|
93
|
+
readonly rootDir: string;
|
|
94
|
+
|
|
95
|
+
private readonly config: ResolvedServerConfig;
|
|
96
|
+
private readonly ownerId: string;
|
|
97
|
+
private readonly processRegistry: LspProcessRegistry;
|
|
98
|
+
private readonly requestTimeoutMs: number;
|
|
99
|
+
private readonly shutdownGraceMs: number;
|
|
100
|
+
private readonly pid: number;
|
|
101
|
+
private readonly process: LspServerProcess;
|
|
102
|
+
private readonly connection: LspConnection;
|
|
103
|
+
private readonly diagnosticsByUri = new Map<string, Diagnostic[]>();
|
|
104
|
+
private readonly documents = new Map<string, LspClientDocument>();
|
|
105
|
+
private readonly disposables: Disposable[] = [];
|
|
106
|
+
private capabilities: ServerCapabilities | undefined;
|
|
107
|
+
private exited = false;
|
|
108
|
+
private initialized = false;
|
|
109
|
+
|
|
110
|
+
constructor(options: LspClientOptions) {
|
|
111
|
+
this.id = options.id;
|
|
112
|
+
this.serverId = options.config.server.id;
|
|
113
|
+
this.rootDir = options.config.rootDir;
|
|
114
|
+
this.config = options.config;
|
|
115
|
+
this.ownerId = options.ownerId;
|
|
116
|
+
this.processRegistry = options.processRegistry;
|
|
117
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
|
|
118
|
+
this.shutdownGraceMs = options.shutdownGraceMs ?? 1_000;
|
|
119
|
+
|
|
120
|
+
this.process = (options.spawner ?? defaultSpawner)(options.config);
|
|
121
|
+
if (!this.process.pid || this.process.pid <= 0) {
|
|
122
|
+
throw new Error(`LSP server ${this.serverId} did not expose a valid pid.`);
|
|
123
|
+
}
|
|
124
|
+
this.pid = this.process.pid;
|
|
125
|
+
|
|
126
|
+
this.connection = (options.connectionFactory ?? defaultConnectionFactory)(this.process);
|
|
127
|
+
this.registerConnectionHandlers();
|
|
128
|
+
this.process.once("exit", () => {
|
|
129
|
+
this.exited = true;
|
|
130
|
+
void this.processRegistry.unregister(this.id, this.pid);
|
|
131
|
+
});
|
|
132
|
+
this.process.once("error", () => {
|
|
133
|
+
this.exited = true;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get isExited(): boolean {
|
|
138
|
+
return this.exited;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async start(): Promise<void> {
|
|
142
|
+
await this.processRegistry.register({
|
|
143
|
+
id: this.id,
|
|
144
|
+
serverId: this.serverId,
|
|
145
|
+
rootDir: this.rootDir,
|
|
146
|
+
pid: this.pid,
|
|
147
|
+
command: this.config.command,
|
|
148
|
+
cwd: this.config.cwd,
|
|
149
|
+
ownerId: this.ownerId,
|
|
150
|
+
ownerPid: process.pid,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this.connection.listen();
|
|
154
|
+
const result = await this.request<InitializeResult>(InitializeRequest.method, this.initializeParams());
|
|
155
|
+
this.capabilities = result.capabilities;
|
|
156
|
+
await this.connection.sendNotification(InitializedNotification.method, {});
|
|
157
|
+
this.initialized = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async syncFile(filePath: string, languageId: string, text: string): Promise<string> {
|
|
161
|
+
const uri = URI.file(filePath).toString();
|
|
162
|
+
const existing = this.documents.get(uri);
|
|
163
|
+
if (!existing) {
|
|
164
|
+
const document: LspClientDocument = { uri, filePath, languageId, text, version: 1 };
|
|
165
|
+
this.documents.set(uri, document);
|
|
166
|
+
await this.connection.sendNotification(DidOpenTextDocumentNotification.method, {
|
|
167
|
+
textDocument: { uri, languageId, version: document.version, text },
|
|
168
|
+
});
|
|
169
|
+
return uri;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (existing.text !== text) {
|
|
173
|
+
existing.version += 1;
|
|
174
|
+
existing.text = text;
|
|
175
|
+
existing.languageId = languageId;
|
|
176
|
+
await this.connection.sendNotification(DidChangeTextDocumentNotification.method, {
|
|
177
|
+
textDocument: { uri, version: existing.version },
|
|
178
|
+
contentChanges: [{ text }],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return uri;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getDiagnostics(uri: string): Diagnostic[] {
|
|
186
|
+
return this.diagnosticsByUri.get(uri) ?? [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async hover(uri: string, line: number, character: number): Promise<Hover | null> {
|
|
190
|
+
this.ensureCapability("hover", this.capabilities?.hoverProvider);
|
|
191
|
+
return this.request<Hover | null>(HoverRequest.method, {
|
|
192
|
+
textDocument: { uri },
|
|
193
|
+
position: { line, character },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async definition(uri: string, line: number, character: number): Promise<Definition | LocationLink[] | null> {
|
|
198
|
+
this.ensureCapability("definition", this.capabilities?.definitionProvider);
|
|
199
|
+
return this.request<Definition | LocationLink[] | null>(DefinitionRequest.method, {
|
|
200
|
+
textDocument: { uri },
|
|
201
|
+
position: { line, character },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async references(
|
|
206
|
+
uri: string,
|
|
207
|
+
line: number,
|
|
208
|
+
character: number,
|
|
209
|
+
includeDeclaration: boolean,
|
|
210
|
+
): Promise<Location[] | null> {
|
|
211
|
+
this.ensureCapability("references", this.capabilities?.referencesProvider);
|
|
212
|
+
const params: ReferenceParams = {
|
|
213
|
+
textDocument: { uri },
|
|
214
|
+
position: { line, character },
|
|
215
|
+
context: { includeDeclaration },
|
|
216
|
+
};
|
|
217
|
+
return this.request<Location[] | null>(ReferencesRequest.method, params);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async documentSymbols(uri: string): Promise<DocumentSymbol[] | SymbolInformation[] | null> {
|
|
221
|
+
this.ensureCapability("document symbols", this.capabilities?.documentSymbolProvider);
|
|
222
|
+
return this.request<DocumentSymbol[] | SymbolInformation[] | null>(DocumentSymbolRequest.method, {
|
|
223
|
+
textDocument: { uri },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async workspaceSymbols(query: string): Promise<SymbolInformation[] | WorkspaceSymbol[] | null> {
|
|
228
|
+
this.ensureCapability("workspace symbols", this.capabilities?.workspaceSymbolProvider);
|
|
229
|
+
return this.request<SymbolInformation[] | WorkspaceSymbol[] | null>(WorkspaceSymbolRequest.method, { query });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async shutdown(): Promise<boolean> {
|
|
233
|
+
if (this.exited) {
|
|
234
|
+
await this.processRegistry.unregister(this.id, this.pid);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (this.initialized) {
|
|
240
|
+
await this.request<void>(ShutdownRequest.method, undefined, Math.min(this.requestTimeoutMs, 2_000));
|
|
241
|
+
}
|
|
242
|
+
await this.connection.sendNotification(ExitNotification.method);
|
|
243
|
+
} catch {
|
|
244
|
+
// Shutdown is best-effort; the process registry performs hard cleanup after this.
|
|
245
|
+
} finally {
|
|
246
|
+
for (const disposable of this.disposables) disposable.dispose();
|
|
247
|
+
this.connection.end();
|
|
248
|
+
this.connection.dispose();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const exited = await this.waitForExit(this.shutdownGraceMs);
|
|
252
|
+
if (exited) {
|
|
253
|
+
await this.processRegistry.unregister(this.id, this.pid);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.process.kill("SIGTERM");
|
|
258
|
+
const terminated = await this.waitForExit(this.shutdownGraceMs);
|
|
259
|
+
if (terminated) {
|
|
260
|
+
await this.processRegistry.unregister(this.id, this.pid);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.process.kill("SIGKILL");
|
|
265
|
+
const killed = await this.waitForExit(this.shutdownGraceMs);
|
|
266
|
+
if (killed) await this.processRegistry.unregister(this.id, this.pid);
|
|
267
|
+
return killed;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private ensureCapability(feature: string, capability: unknown): void {
|
|
271
|
+
if (!capability) {
|
|
272
|
+
throw new Error(`${this.serverId} does not support LSP ${feature}.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private initializeParams(): InitializeParams {
|
|
277
|
+
const rootUri = URI.file(this.rootDir).toString();
|
|
278
|
+
const workspaceFolders: WorkspaceFolder[] = [{ uri: rootUri, name: basename(this.rootDir) || this.rootDir }];
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
processId: process.pid,
|
|
282
|
+
clientInfo: { name: "pi-agent-lsp" },
|
|
283
|
+
rootPath: this.rootDir,
|
|
284
|
+
rootUri,
|
|
285
|
+
workspaceFolders,
|
|
286
|
+
capabilities: {
|
|
287
|
+
textDocument: {
|
|
288
|
+
synchronization: { dynamicRegistration: false, willSave: false, willSaveWaitUntil: false, didSave: false },
|
|
289
|
+
hover: { dynamicRegistration: false, contentFormat: ["markdown", "plaintext"] },
|
|
290
|
+
definition: { dynamicRegistration: false, linkSupport: true },
|
|
291
|
+
references: { dynamicRegistration: false },
|
|
292
|
+
documentSymbol: {
|
|
293
|
+
dynamicRegistration: false,
|
|
294
|
+
hierarchicalDocumentSymbolSupport: true,
|
|
295
|
+
symbolKind: { valueSet: supportedSymbolKinds() },
|
|
296
|
+
},
|
|
297
|
+
publishDiagnostics: { relatedInformation: true, tagSupport: { valueSet: [1, 2] } },
|
|
298
|
+
},
|
|
299
|
+
workspace: {
|
|
300
|
+
configuration: true,
|
|
301
|
+
workspaceFolders: true,
|
|
302
|
+
symbol: { dynamicRegistration: false, symbolKind: { valueSet: supportedSymbolKinds() } },
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
initializationOptions: this.config.initializationOptions,
|
|
306
|
+
trace: "off",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private registerConnectionHandlers(): void {
|
|
311
|
+
this.disposables.push(
|
|
312
|
+
this.connection.onNotification(PublishDiagnosticsNotification.method, (params) => {
|
|
313
|
+
if (!isPublishDiagnosticsParams(params)) return;
|
|
314
|
+
this.diagnosticsByUri.set(params.uri, params.diagnostics);
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
this.disposables.push(
|
|
319
|
+
this.connection.onRequest("workspace/configuration", (params) =>
|
|
320
|
+
resolveConfigurationRequest(params, this.config.settings),
|
|
321
|
+
),
|
|
322
|
+
this.connection.onRequest("workspace/workspaceFolders", () => [
|
|
323
|
+
{ uri: URI.file(this.rootDir).toString(), name: basename(this.rootDir) },
|
|
324
|
+
]),
|
|
325
|
+
this.connection.onRequest("client/registerCapability", () => null),
|
|
326
|
+
this.connection.onRequest("client/unregisterCapability", () => null),
|
|
327
|
+
this.connection.onRequest("window/workDoneProgress/create", () => null),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async request<T>(method: string, params?: unknown, timeoutMs = this.requestTimeoutMs): Promise<T> {
|
|
332
|
+
return withTimeout(this.connection.sendRequest<T>(method, params), timeoutMs, `${this.serverId} ${method}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async waitForExit(timeoutMs: number): Promise<boolean> {
|
|
336
|
+
if (this.exited) return true;
|
|
337
|
+
return new Promise((resolvePromise) => {
|
|
338
|
+
const timeout = setTimeout(() => resolvePromise(false), timeoutMs);
|
|
339
|
+
this.process.once("exit", () => {
|
|
340
|
+
clearTimeout(timeout);
|
|
341
|
+
resolvePromise(true);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function defaultSpawner(config: ResolvedServerConfig): ChildProcessWithoutNullStreams {
|
|
348
|
+
const [command, ...args] = config.command;
|
|
349
|
+
if (!command) throw new Error(`LSP server ${config.server.id} has an empty command.`);
|
|
350
|
+
const child = spawn(command, args, {
|
|
351
|
+
cwd: config.cwd,
|
|
352
|
+
env: config.env,
|
|
353
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
354
|
+
shell: false,
|
|
355
|
+
});
|
|
356
|
+
pipeStderrToLogFile(child, config.server.id, child.pid);
|
|
357
|
+
child.on("error", () => undefined);
|
|
358
|
+
return child;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const LSP_STDERR_LOG_MAX_BYTES = 256 * 1024;
|
|
362
|
+
|
|
363
|
+
function pipeStderrToLogFile(child: ChildProcessWithoutNullStreams, serverId: string, pid: number | undefined): void {
|
|
364
|
+
const logPath = join(getLogsDir(), `${serverId}-${pid ?? "unknown"}-stderr.log`);
|
|
365
|
+
let bytes = 0;
|
|
366
|
+
let closed = false;
|
|
367
|
+
let stream: WriteStream | undefined;
|
|
368
|
+
|
|
369
|
+
const openStream = () => {
|
|
370
|
+
if (stream) return;
|
|
371
|
+
stream = createWriteStream(logPath, { flags: "w" });
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
375
|
+
if (closed) return;
|
|
376
|
+
const size = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
|
377
|
+
bytes += size;
|
|
378
|
+
if (bytes > LSP_STDERR_LOG_MAX_BYTES) return;
|
|
379
|
+
openStream();
|
|
380
|
+
stream?.write(chunk);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
child.on("exit", () => {
|
|
384
|
+
closed = true;
|
|
385
|
+
stream?.end();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function defaultConnectionFactory(process: LspServerProcess): LspConnection {
|
|
390
|
+
if (!hasStdio(process)) {
|
|
391
|
+
throw new Error("Cannot create an LSP connection for a process without stdio pipes.");
|
|
392
|
+
}
|
|
393
|
+
return createMessageConnection(
|
|
394
|
+
new StreamMessageReader(process.stdout),
|
|
395
|
+
new StreamMessageWriter(process.stdin),
|
|
396
|
+
) as LspConnection;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function hasStdio(
|
|
400
|
+
process: LspServerProcess,
|
|
401
|
+
): process is LspServerProcess & Pick<ChildProcessWithoutNullStreams, "stdin" | "stdout"> {
|
|
402
|
+
return "stdin" in process && "stdout" in process && process.stdin !== null && process.stdout !== null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function supportedSymbolKinds(): SymbolKind[] {
|
|
406
|
+
return Array.from({ length: 26 }, (_value, index) => index + 1) as SymbolKind[];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isPublishDiagnosticsParams(value: unknown): value is { uri: string; diagnostics: Diagnostic[] } {
|
|
410
|
+
return (
|
|
411
|
+
typeof value === "object" &&
|
|
412
|
+
value !== null &&
|
|
413
|
+
"uri" in value &&
|
|
414
|
+
typeof value.uri === "string" &&
|
|
415
|
+
"diagnostics" in value &&
|
|
416
|
+
Array.isArray(value.diagnostics)
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function resolveConfigurationRequest(params: unknown, settings: JsonObject): JsonValue[] {
|
|
421
|
+
if (!isConfigurationParams(params)) return [settings];
|
|
422
|
+
return params.items.map((item) => resolveSection(settings, item.section));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isConfigurationParams(value: unknown): value is { items: Array<{ section?: string }> } {
|
|
426
|
+
return (
|
|
427
|
+
typeof value === "object" &&
|
|
428
|
+
value !== null &&
|
|
429
|
+
"items" in value &&
|
|
430
|
+
Array.isArray(value.items) &&
|
|
431
|
+
value.items.every((item) => typeof item === "object" && item !== null)
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function resolveSection(settings: JsonObject, section: string | undefined): JsonValue {
|
|
436
|
+
if (!section) return settings;
|
|
437
|
+
|
|
438
|
+
let current: JsonValue = settings;
|
|
439
|
+
for (const segment of section.split(".")) {
|
|
440
|
+
if (!isJsonObject(current)) return {};
|
|
441
|
+
const sectionValue: JsonValue | undefined = current[segment];
|
|
442
|
+
if (sectionValue === undefined) return {};
|
|
443
|
+
current = sectionValue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return current;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isJsonObject(value: JsonValue): value is JsonObject {
|
|
450
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
454
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
455
|
+
try {
|
|
456
|
+
return await Promise.race([
|
|
457
|
+
promise,
|
|
458
|
+
new Promise<T>((_resolve, reject) => {
|
|
459
|
+
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
|
|
460
|
+
}),
|
|
461
|
+
]);
|
|
462
|
+
} finally {
|
|
463
|
+
if (timeout) clearTimeout(timeout);
|
|
464
|
+
}
|
|
465
|
+
}
|