@ian-pascoe/pi-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/LICENSE +21 -0
- package/README.md +187 -0
- package/package.json +57 -0
- package/src/index.ts +1 -0
- package/src/lsp-position-encoding.ts +134 -0
- package/src/lsp-post-edit-diagnostics-rendering.ts +249 -0
- package/src/lsp-post-edit-diagnostics.ts +291 -0
- package/src/lsp-server-client.ts +1237 -0
- package/src/lsp-server-manager.ts +519 -0
- package/src/lsp-session-files.ts +107 -0
- package/src/lsp-tool-contract.ts +468 -0
- package/src/lsp-tool-output.ts +64 -0
- package/src/lsp-tool-rendering.ts +312 -0
- package/src/lsp-tool.ts +1214 -0
- package/src/lsp-workspace-edit.ts +872 -0
- package/src/pi-lsp-extension.ts +379 -0
- package/src/pi-lsp-settings.ts +263 -0
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
|
+
import spawn from "cross-spawn";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { Value } from "typebox/value";
|
|
8
|
+
import {
|
|
9
|
+
ApplyWorkspaceEditRequest,
|
|
10
|
+
CancellationTokenSource,
|
|
11
|
+
ConfigurationRequest,
|
|
12
|
+
createProtocolConnection,
|
|
13
|
+
DiagnosticRefreshRequest,
|
|
14
|
+
DidChangeConfigurationNotification,
|
|
15
|
+
DidChangeTextDocumentNotification,
|
|
16
|
+
DidCloseTextDocumentNotification,
|
|
17
|
+
DidOpenTextDocumentNotification,
|
|
18
|
+
DidSaveTextDocumentNotification,
|
|
19
|
+
DocumentDiagnosticReportKind,
|
|
20
|
+
DocumentDiagnosticRequest,
|
|
21
|
+
ExitNotification,
|
|
22
|
+
InitializedNotification,
|
|
23
|
+
InitializeRequest,
|
|
24
|
+
LogMessageNotification,
|
|
25
|
+
PositionEncodingKind,
|
|
26
|
+
PublishDiagnosticsNotification,
|
|
27
|
+
RegistrationRequest,
|
|
28
|
+
ShutdownRequest,
|
|
29
|
+
ShowMessageNotification,
|
|
30
|
+
ShowMessageRequest,
|
|
31
|
+
TextDocumentSyncKind,
|
|
32
|
+
UnregistrationRequest,
|
|
33
|
+
WorkDoneProgressCreateRequest,
|
|
34
|
+
WorkspaceDiagnosticRequest,
|
|
35
|
+
WorkspaceFoldersRequest,
|
|
36
|
+
type ApplyWorkspaceEditParams,
|
|
37
|
+
type ApplyWorkspaceEditResult,
|
|
38
|
+
type Diagnostic,
|
|
39
|
+
type DocumentDiagnosticReport,
|
|
40
|
+
type InitializeResult,
|
|
41
|
+
type LSPAny,
|
|
42
|
+
type Position,
|
|
43
|
+
type ProtocolConnection,
|
|
44
|
+
type Registration,
|
|
45
|
+
type ServerCapabilities,
|
|
46
|
+
type Unregistration,
|
|
47
|
+
type WorkspaceDiagnosticReport,
|
|
48
|
+
type WorkspaceEdit,
|
|
49
|
+
} from "vscode-languageserver-protocol/node";
|
|
50
|
+
import {
|
|
51
|
+
measureLspPositionCharacters,
|
|
52
|
+
normalizeLspPositionEncoding,
|
|
53
|
+
type LspPositionEncoding,
|
|
54
|
+
} from "./lsp-position-encoding.js";
|
|
55
|
+
|
|
56
|
+
const MAX_OPEN_DOCUMENTS = 100;
|
|
57
|
+
const MAX_STDERR_BYTES = 1024 * 1024;
|
|
58
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
59
|
+
const LspJsonObjectSchema = Type.Record(Type.String(), Type.Unsafe<LSPAny>({}));
|
|
60
|
+
const TextDocumentSyncOptionsSchema = Type.Object(
|
|
61
|
+
{
|
|
62
|
+
change: Type.Optional(Type.Integer()),
|
|
63
|
+
save: Type.Optional(
|
|
64
|
+
Type.Union([Type.Boolean(), Type.Object({}, { additionalProperties: true })]),
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
{ additionalProperties: true },
|
|
68
|
+
);
|
|
69
|
+
const DynamicDiagnosticRegistrationSchema = Type.Object(
|
|
70
|
+
{
|
|
71
|
+
identifier: Type.Optional(Type.String()),
|
|
72
|
+
workspaceDiagnostics: Type.Optional(Type.Boolean()),
|
|
73
|
+
},
|
|
74
|
+
{ additionalProperties: true },
|
|
75
|
+
);
|
|
76
|
+
const PrepareRenameProviderSchema = Type.Object(
|
|
77
|
+
{ prepareProvider: Type.Literal(true) },
|
|
78
|
+
{ additionalProperties: true },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/** Time budgets, in milliseconds, for one language-server process. */
|
|
82
|
+
export interface LspServerClientTimeouts {
|
|
83
|
+
/** Initialize request budget. */
|
|
84
|
+
readonly initializeMs: number;
|
|
85
|
+
/** Ordinary request budget. */
|
|
86
|
+
readonly requestMs: number;
|
|
87
|
+
/** Fresh diagnostics budget. */
|
|
88
|
+
readonly diagnosticsMs: number;
|
|
89
|
+
/** Graceful shutdown budget before process termination. */
|
|
90
|
+
readonly shutdownMs: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Launch and protocol configuration for one language-server process. */
|
|
94
|
+
export interface LspServerClientOptions {
|
|
95
|
+
/** Stable configured server ID. */
|
|
96
|
+
readonly serverId: string;
|
|
97
|
+
/** Absolute root used as process cwd and workspace folder. */
|
|
98
|
+
readonly rootPath: string;
|
|
99
|
+
/** Installed executable resolved by cross-spawn without a shell. */
|
|
100
|
+
readonly command: string;
|
|
101
|
+
/** Executable arguments passed without shell interpretation. */
|
|
102
|
+
readonly args: readonly string[];
|
|
103
|
+
/** Complete child environment after settings resolution. */
|
|
104
|
+
readonly environment: NodeJS.ProcessEnv;
|
|
105
|
+
/** Opaque value sent only in the initialize request. */
|
|
106
|
+
readonly initializationOptions: LSPAny;
|
|
107
|
+
/** Opaque value served through workspace configuration. */
|
|
108
|
+
readonly settings: LSPAny;
|
|
109
|
+
/** Per-operation time budgets. */
|
|
110
|
+
readonly timeouts: LspServerClientTimeouts;
|
|
111
|
+
/** Mode-safe session file that retains the latest 1 MB of server stderr. */
|
|
112
|
+
readonly stderrPath: string;
|
|
113
|
+
/** Convert a server-initiated edit into a Workspace Edit Preview. */
|
|
114
|
+
readonly onWorkspaceEdit?: (workspaceEdit: WorkspaceEdit) => Promise<string>;
|
|
115
|
+
/** Mark the owning Server Instance unavailable after its first terminal process/protocol failure. */
|
|
116
|
+
readonly onUnavailable?: (error: LspServerClientError) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A valid UTF-8 document synchronized with one server instance. */
|
|
120
|
+
export interface LspSynchronizedDocument {
|
|
121
|
+
/** Absolute file path. */
|
|
122
|
+
readonly filePath: string;
|
|
123
|
+
/** File URI sent to the server. */
|
|
124
|
+
readonly uri: string;
|
|
125
|
+
/** Monotonic document version local to this server instance. */
|
|
126
|
+
readonly version: number;
|
|
127
|
+
/** Decoded text, including an initial UTF-8 BOM when present. */
|
|
128
|
+
readonly text: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fresh diagnostics or a distinct timeout outcome. */
|
|
132
|
+
export type LspDocumentDiagnosticResult =
|
|
133
|
+
| {
|
|
134
|
+
readonly status: "fresh";
|
|
135
|
+
readonly source: "push" | "document_pull";
|
|
136
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
137
|
+
}
|
|
138
|
+
| { readonly status: "timeout"; readonly diagnostics: readonly [] };
|
|
139
|
+
|
|
140
|
+
/** Workspace diagnostics grouped by URI, with cached push fallback only when pull is unsupported. */
|
|
141
|
+
export type LspWorkspaceDiagnosticResult =
|
|
142
|
+
| {
|
|
143
|
+
readonly status: "fresh";
|
|
144
|
+
readonly source: "workspace_pull" | "push_cache";
|
|
145
|
+
readonly diagnosticsByUri: ReadonlyMap<string, readonly Diagnostic[]>;
|
|
146
|
+
}
|
|
147
|
+
| { readonly status: "timeout"; readonly diagnosticsByUri: ReadonlyMap<string, never> };
|
|
148
|
+
|
|
149
|
+
/** Classified process, protocol, timeout, cancellation, and UTF-8 client failure. */
|
|
150
|
+
export class LspServerClientError extends Error {
|
|
151
|
+
readonly _tag = "LspServerClientError" as const;
|
|
152
|
+
|
|
153
|
+
/** Construct a stable Pi LSP client failure that always names the stderr capture. */
|
|
154
|
+
constructor(
|
|
155
|
+
readonly kind:
|
|
156
|
+
| "cancelled"
|
|
157
|
+
| "exit"
|
|
158
|
+
| "initialize"
|
|
159
|
+
| "invalid_utf8"
|
|
160
|
+
| "protocol"
|
|
161
|
+
| "spawn"
|
|
162
|
+
| "timeout",
|
|
163
|
+
readonly serverId: string,
|
|
164
|
+
readonly stderrPath: string,
|
|
165
|
+
message: string,
|
|
166
|
+
options?: ErrorOptions,
|
|
167
|
+
) {
|
|
168
|
+
super(`Pi LSP: ${message} (server ${serverId}; stderr ${stderrPath})`, options);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface OpenDocumentState extends LspSynchronizedDocument {
|
|
173
|
+
readonly languageId: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface PushDiagnosticsState {
|
|
177
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
178
|
+
readonly revision: number;
|
|
179
|
+
readonly version?: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface PullDiagnosticsState {
|
|
183
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
184
|
+
readonly resultId?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface DynamicDiagnosticRegistration {
|
|
188
|
+
readonly identifier?: string;
|
|
189
|
+
readonly workspaceDiagnostics: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isProcessWithStdio(
|
|
193
|
+
childProcess: ReturnType<typeof spawn>,
|
|
194
|
+
): childProcess is ChildProcessWithoutNullStreams {
|
|
195
|
+
return (
|
|
196
|
+
childProcess.stdin !== null && childProcess.stdout !== null && childProcess.stderr !== null
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function syncKindFromCapabilities(capabilities: ServerCapabilities): TextDocumentSyncKind {
|
|
201
|
+
const sync = capabilities.textDocumentSync;
|
|
202
|
+
if (!Value.Check(TextDocumentSyncOptionsSchema, sync)) {
|
|
203
|
+
return sync ?? TextDocumentSyncKind.None;
|
|
204
|
+
}
|
|
205
|
+
const change = sync.change;
|
|
206
|
+
return change === TextDocumentSyncKind.Full || change === TextDocumentSyncKind.Incremental
|
|
207
|
+
? change
|
|
208
|
+
: TextDocumentSyncKind.None;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function serverWantsSave(capabilities: ServerCapabilities): boolean {
|
|
212
|
+
const sync = capabilities.textDocumentSync;
|
|
213
|
+
return (
|
|
214
|
+
Value.Check(TextDocumentSyncOptionsSchema, sync) &&
|
|
215
|
+
sync.save !== undefined &&
|
|
216
|
+
sync.save !== false
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function protocolLineEndPosition(text: string, encoding: LspPositionEncoding): Position {
|
|
221
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
222
|
+
const lineText = lines.at(-1) ?? "";
|
|
223
|
+
return {
|
|
224
|
+
line: lines.length - 1,
|
|
225
|
+
character: measureLspPositionCharacters(lineText, encoding),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function configurationSectionValue(settings: LSPAny, section: string | undefined): LSPAny {
|
|
230
|
+
if (section === undefined || section.length === 0) return settings;
|
|
231
|
+
let current: LSPAny = settings;
|
|
232
|
+
for (const part of section.split(".")) {
|
|
233
|
+
if (!Value.Check(LspJsonObjectSchema, current) || !(part in current)) return null;
|
|
234
|
+
current = current[part] ?? null;
|
|
235
|
+
}
|
|
236
|
+
return current;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function appendTail(previous: Buffer, chunk: Buffer): Buffer {
|
|
240
|
+
const combined = Buffer.concat([previous, chunk]);
|
|
241
|
+
return combined.length <= MAX_STDERR_BYTES
|
|
242
|
+
? combined
|
|
243
|
+
: combined.subarray(combined.length - MAX_STDERR_BYTES);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function abortError(options: LspServerClientOptions): LspServerClientError {
|
|
247
|
+
return new LspServerClientError(
|
|
248
|
+
"cancelled",
|
|
249
|
+
options.serverId,
|
|
250
|
+
options.stderrPath,
|
|
251
|
+
"request cancelled",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function timeoutError(options: LspServerClientOptions, operation: string): LspServerClientError {
|
|
256
|
+
return new LspServerClientError(
|
|
257
|
+
"timeout",
|
|
258
|
+
options.serverId,
|
|
259
|
+
options.stderrPath,
|
|
260
|
+
`${operation} timed out`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Own one stdio LSP process, connection, synchronized-document cache, and diagnostics state. */
|
|
265
|
+
export class LspServerClient {
|
|
266
|
+
private capabilitiesValue: ServerCapabilities = {};
|
|
267
|
+
private serverInfoValue: InitializeResult["serverInfo"];
|
|
268
|
+
private positionEncodingValue: LspPositionEncoding = "utf-16";
|
|
269
|
+
private textDocumentSyncKind: TextDocumentSyncKind = TextDocumentSyncKind.None;
|
|
270
|
+
private readonly openDocuments = new Map<string, OpenDocumentState>();
|
|
271
|
+
private readonly pushDiagnostics = new Map<string, PushDiagnosticsState>();
|
|
272
|
+
private readonly pullDiagnostics = new Map<string, PullDiagnosticsState>();
|
|
273
|
+
private readonly dynamicRegistrations = new Map<string, Registration>();
|
|
274
|
+
private readonly diagnosticWaiters = new Map<string, Set<() => void>>();
|
|
275
|
+
private readonly protocolMessages: string[] = [];
|
|
276
|
+
private diagnosticsRevision = 0;
|
|
277
|
+
private diagnosticsRefreshRevision = 0;
|
|
278
|
+
private stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
279
|
+
private stderrWrite = Promise.resolve();
|
|
280
|
+
private closing = false;
|
|
281
|
+
private closed = false;
|
|
282
|
+
private terminalError: LspServerClientError | undefined;
|
|
283
|
+
private resolveTerminalError: ((error: LspServerClientError) => void) | undefined;
|
|
284
|
+
private readonly terminalErrorPromise: Promise<LspServerClientError>;
|
|
285
|
+
|
|
286
|
+
private constructor(
|
|
287
|
+
private readonly options: LspServerClientOptions,
|
|
288
|
+
private readonly childProcess: ChildProcessWithoutNullStreams,
|
|
289
|
+
private readonly connection: ProtocolConnection,
|
|
290
|
+
) {
|
|
291
|
+
this.terminalErrorPromise = new Promise((resolveTerminalError) => {
|
|
292
|
+
this.resolveTerminalError = resolveTerminalError;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Spawn and initialize one configured language server over stdio. */
|
|
297
|
+
static async start(options: LspServerClientOptions): Promise<LspServerClient> {
|
|
298
|
+
await mkdir(dirname(options.stderrPath), { recursive: true, mode: 0o700 });
|
|
299
|
+
await writeFile(options.stderrPath, "", { mode: 0o600 });
|
|
300
|
+
|
|
301
|
+
const childProcess = spawn(options.command, [...options.args], {
|
|
302
|
+
cwd: options.rootPath,
|
|
303
|
+
env: options.environment,
|
|
304
|
+
shell: false,
|
|
305
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
306
|
+
});
|
|
307
|
+
if (!isProcessWithStdio(childProcess)) {
|
|
308
|
+
childProcess.kill();
|
|
309
|
+
throw new LspServerClientError(
|
|
310
|
+
"spawn",
|
|
311
|
+
options.serverId,
|
|
312
|
+
options.stderrPath,
|
|
313
|
+
"spawn did not provide stdio pipes",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const connection = createProtocolConnection(childProcess.stdout, childProcess.stdin);
|
|
318
|
+
const client = new LspServerClient(options, childProcess, connection);
|
|
319
|
+
client.bindProcessLifecycle();
|
|
320
|
+
client.bindProtocolHandlers();
|
|
321
|
+
connection.listen();
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await client.initialize();
|
|
325
|
+
return client;
|
|
326
|
+
} catch (cause) {
|
|
327
|
+
await client.forceStop();
|
|
328
|
+
if (cause instanceof LspServerClientError) throw cause;
|
|
329
|
+
throw new LspServerClientError(
|
|
330
|
+
"initialize",
|
|
331
|
+
options.serverId,
|
|
332
|
+
options.stderrPath,
|
|
333
|
+
"initialization failed",
|
|
334
|
+
{ cause },
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Child process identifier while the server is running. */
|
|
340
|
+
get processId(): number | undefined {
|
|
341
|
+
return this.childProcess.pid;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Whether the child has not exited and shutdown has not started. */
|
|
345
|
+
get isRunning(): boolean {
|
|
346
|
+
return (
|
|
347
|
+
!this.closing &&
|
|
348
|
+
!this.closed &&
|
|
349
|
+
this.childProcess.exitCode === null &&
|
|
350
|
+
this.childProcess.signalCode === null
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Stable configured server ID. */
|
|
355
|
+
get serverId(): string {
|
|
356
|
+
return this.options.serverId;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Absolute workspace root for this process. */
|
|
360
|
+
get rootPath(): string {
|
|
361
|
+
return this.options.rootPath;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Server capabilities after initialization plus dynamic registrations. */
|
|
365
|
+
get capabilities(): Readonly<ServerCapabilities> {
|
|
366
|
+
return this.capabilitiesValue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Optional server implementation name and version returned during initialization. */
|
|
370
|
+
get serverInfo(): InitializeResult["serverInfo"] {
|
|
371
|
+
return this.serverInfoValue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Position encoding negotiated with the server, defaulting to UTF-16. */
|
|
375
|
+
get positionEncoding(): LspPositionEncoding {
|
|
376
|
+
return this.positionEncodingValue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Session stderr capture path included in every client failure. */
|
|
380
|
+
get stderrPath(): string {
|
|
381
|
+
return this.options.stderrPath;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Most recent bounded protocol log/show messages, oldest first. */
|
|
385
|
+
get recentProtocolMessages(): readonly string[] {
|
|
386
|
+
return this.protocolMessages;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Whether a static or dynamically registered LSP method is available. */
|
|
390
|
+
hasCapability(method: string): boolean {
|
|
391
|
+
if (
|
|
392
|
+
[...this.dynamicRegistrations.values()].some((registration) => registration.method === method)
|
|
393
|
+
) {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
const capabilities = this.capabilitiesValue;
|
|
397
|
+
switch (method) {
|
|
398
|
+
case "textDocument/completion":
|
|
399
|
+
return capabilities.completionProvider !== undefined;
|
|
400
|
+
case "textDocument/hover":
|
|
401
|
+
return capabilities.hoverProvider !== undefined && capabilities.hoverProvider !== false;
|
|
402
|
+
case "textDocument/signatureHelp":
|
|
403
|
+
return capabilities.signatureHelpProvider !== undefined;
|
|
404
|
+
case "textDocument/declaration":
|
|
405
|
+
return (
|
|
406
|
+
capabilities.declarationProvider !== undefined &&
|
|
407
|
+
capabilities.declarationProvider !== false
|
|
408
|
+
);
|
|
409
|
+
case "textDocument/definition":
|
|
410
|
+
return (
|
|
411
|
+
capabilities.definitionProvider !== undefined && capabilities.definitionProvider !== false
|
|
412
|
+
);
|
|
413
|
+
case "textDocument/typeDefinition":
|
|
414
|
+
return (
|
|
415
|
+
capabilities.typeDefinitionProvider !== undefined &&
|
|
416
|
+
capabilities.typeDefinitionProvider !== false
|
|
417
|
+
);
|
|
418
|
+
case "textDocument/implementation":
|
|
419
|
+
return (
|
|
420
|
+
capabilities.implementationProvider !== undefined &&
|
|
421
|
+
capabilities.implementationProvider !== false
|
|
422
|
+
);
|
|
423
|
+
case "textDocument/references":
|
|
424
|
+
return (
|
|
425
|
+
capabilities.referencesProvider !== undefined && capabilities.referencesProvider !== false
|
|
426
|
+
);
|
|
427
|
+
case "textDocument/documentHighlight":
|
|
428
|
+
return (
|
|
429
|
+
capabilities.documentHighlightProvider !== undefined &&
|
|
430
|
+
capabilities.documentHighlightProvider !== false
|
|
431
|
+
);
|
|
432
|
+
case "textDocument/documentSymbol":
|
|
433
|
+
return (
|
|
434
|
+
capabilities.documentSymbolProvider !== undefined &&
|
|
435
|
+
capabilities.documentSymbolProvider !== false
|
|
436
|
+
);
|
|
437
|
+
case "workspace/symbol":
|
|
438
|
+
return (
|
|
439
|
+
capabilities.workspaceSymbolProvider !== undefined &&
|
|
440
|
+
capabilities.workspaceSymbolProvider !== false
|
|
441
|
+
);
|
|
442
|
+
case "textDocument/documentLink":
|
|
443
|
+
return capabilities.documentLinkProvider !== undefined;
|
|
444
|
+
case "textDocument/prepareCallHierarchy":
|
|
445
|
+
return (
|
|
446
|
+
capabilities.callHierarchyProvider !== undefined &&
|
|
447
|
+
capabilities.callHierarchyProvider !== false
|
|
448
|
+
);
|
|
449
|
+
case "textDocument/prepareTypeHierarchy":
|
|
450
|
+
return (
|
|
451
|
+
capabilities.typeHierarchyProvider !== undefined &&
|
|
452
|
+
capabilities.typeHierarchyProvider !== false
|
|
453
|
+
);
|
|
454
|
+
case "textDocument/selectionRange":
|
|
455
|
+
return (
|
|
456
|
+
capabilities.selectionRangeProvider !== undefined &&
|
|
457
|
+
capabilities.selectionRangeProvider !== false
|
|
458
|
+
);
|
|
459
|
+
case "textDocument/foldingRange":
|
|
460
|
+
return (
|
|
461
|
+
capabilities.foldingRangeProvider !== undefined &&
|
|
462
|
+
capabilities.foldingRangeProvider !== false
|
|
463
|
+
);
|
|
464
|
+
case "textDocument/codeLens":
|
|
465
|
+
return capabilities.codeLensProvider !== undefined;
|
|
466
|
+
case "textDocument/inlayHint":
|
|
467
|
+
return (
|
|
468
|
+
capabilities.inlayHintProvider !== undefined && capabilities.inlayHintProvider !== false
|
|
469
|
+
);
|
|
470
|
+
case "textDocument/documentColor":
|
|
471
|
+
return capabilities.colorProvider !== undefined && capabilities.colorProvider !== false;
|
|
472
|
+
case "textDocument/formatting":
|
|
473
|
+
return (
|
|
474
|
+
capabilities.documentFormattingProvider !== undefined &&
|
|
475
|
+
capabilities.documentFormattingProvider !== false
|
|
476
|
+
);
|
|
477
|
+
case "textDocument/rangeFormatting":
|
|
478
|
+
return (
|
|
479
|
+
capabilities.documentRangeFormattingProvider !== undefined &&
|
|
480
|
+
capabilities.documentRangeFormattingProvider !== false
|
|
481
|
+
);
|
|
482
|
+
case "textDocument/onTypeFormatting":
|
|
483
|
+
return capabilities.documentOnTypeFormattingProvider !== undefined;
|
|
484
|
+
case "textDocument/prepareRename":
|
|
485
|
+
return (
|
|
486
|
+
Value.Check(PrepareRenameProviderSchema, capabilities.renameProvider) ||
|
|
487
|
+
[...this.dynamicRegistrations.values()].some(
|
|
488
|
+
(registration) =>
|
|
489
|
+
registration.method === "textDocument/rename" &&
|
|
490
|
+
Value.Check(PrepareRenameProviderSchema, registration.registerOptions),
|
|
491
|
+
)
|
|
492
|
+
);
|
|
493
|
+
case "textDocument/rename":
|
|
494
|
+
return capabilities.renameProvider !== undefined && capabilities.renameProvider !== false;
|
|
495
|
+
case "textDocument/codeAction":
|
|
496
|
+
return (
|
|
497
|
+
capabilities.codeActionProvider !== undefined && capabilities.codeActionProvider !== false
|
|
498
|
+
);
|
|
499
|
+
case DocumentDiagnosticRequest.method:
|
|
500
|
+
return this.documentPullRegistration() !== undefined;
|
|
501
|
+
case WorkspaceDiagnosticRequest.method:
|
|
502
|
+
return this.workspacePullRegistration() !== undefined;
|
|
503
|
+
default:
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Send a capability-specific request with the configured timeout and JSON-RPC cancellation. */
|
|
509
|
+
async request<TResult>(
|
|
510
|
+
method: string,
|
|
511
|
+
parameters: LSPAny,
|
|
512
|
+
signal?: AbortSignal,
|
|
513
|
+
): Promise<TResult> {
|
|
514
|
+
return this.sendRequestWithBudget<TResult>(
|
|
515
|
+
method,
|
|
516
|
+
parameters,
|
|
517
|
+
this.options.timeouts.requestMs,
|
|
518
|
+
method,
|
|
519
|
+
signal,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Open or update a valid UTF-8 file and maintain the 100-document LRU. */
|
|
524
|
+
async synchronizeDocument(
|
|
525
|
+
filePath: string,
|
|
526
|
+
languageId: string,
|
|
527
|
+
): Promise<LspSynchronizedDocument> {
|
|
528
|
+
this.throwIfUnavailable();
|
|
529
|
+
const absolutePath = resolve(filePath);
|
|
530
|
+
let text: string;
|
|
531
|
+
try {
|
|
532
|
+
const bytes = await readFile(absolutePath);
|
|
533
|
+
text = UTF8_DECODER.decode(bytes);
|
|
534
|
+
} catch (cause) {
|
|
535
|
+
if (cause instanceof TypeError) {
|
|
536
|
+
throw new LspServerClientError(
|
|
537
|
+
"invalid_utf8",
|
|
538
|
+
this.serverId,
|
|
539
|
+
this.stderrPath,
|
|
540
|
+
`document is not valid UTF-8: ${absolutePath}`,
|
|
541
|
+
{ cause },
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
throw cause;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const uri = pathToFileURL(absolutePath).href;
|
|
548
|
+
const existing = this.openDocuments.get(uri);
|
|
549
|
+
const next: OpenDocumentState = {
|
|
550
|
+
filePath: absolutePath,
|
|
551
|
+
uri,
|
|
552
|
+
version: (existing?.version ?? 0) + 1,
|
|
553
|
+
text,
|
|
554
|
+
languageId,
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
if (this.textDocumentSyncKind !== TextDocumentSyncKind.None) {
|
|
558
|
+
if (existing === undefined) {
|
|
559
|
+
await this.connection.sendNotification(DidOpenTextDocumentNotification.type, {
|
|
560
|
+
textDocument: { uri, languageId, version: next.version, text },
|
|
561
|
+
});
|
|
562
|
+
} else if (existing.text !== text || existing.languageId !== languageId) {
|
|
563
|
+
const contentChanges =
|
|
564
|
+
this.textDocumentSyncKind === TextDocumentSyncKind.Incremental
|
|
565
|
+
? [
|
|
566
|
+
{
|
|
567
|
+
range: {
|
|
568
|
+
start: { line: 0, character: 0 },
|
|
569
|
+
end: protocolLineEndPosition(existing.text, this.positionEncodingValue),
|
|
570
|
+
},
|
|
571
|
+
text,
|
|
572
|
+
},
|
|
573
|
+
]
|
|
574
|
+
: [{ text }];
|
|
575
|
+
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
576
|
+
textDocument: { uri, version: next.version },
|
|
577
|
+
contentChanges,
|
|
578
|
+
});
|
|
579
|
+
if (serverWantsSave(this.capabilitiesValue)) {
|
|
580
|
+
await this.connection.sendNotification(DidSaveTextDocumentNotification.type, {
|
|
581
|
+
textDocument: { uri },
|
|
582
|
+
text,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this.openDocuments.delete(uri);
|
|
589
|
+
this.openDocuments.set(uri, next);
|
|
590
|
+
await this.evictOldDocuments();
|
|
591
|
+
return next;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Close an open document if this client currently owns it. */
|
|
595
|
+
async closeDocument(filePath: string): Promise<void> {
|
|
596
|
+
const uri = pathToFileURL(resolve(filePath)).href;
|
|
597
|
+
if (!this.openDocuments.delete(uri)) return;
|
|
598
|
+
if (this.textDocumentSyncKind !== TextDocumentSyncKind.None) {
|
|
599
|
+
await this.connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
600
|
+
textDocument: { uri },
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
this.pushDiagnostics.delete(uri);
|
|
604
|
+
this.pullDiagnostics.delete(uri);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Synchronize a document, then wait for authoritative fresh push or pull diagnostics. */
|
|
608
|
+
async documentDiagnostics(
|
|
609
|
+
filePath: string,
|
|
610
|
+
languageId: string,
|
|
611
|
+
signal?: AbortSignal,
|
|
612
|
+
): Promise<LspDocumentDiagnosticResult> {
|
|
613
|
+
const uri = pathToFileURL(resolve(filePath)).href;
|
|
614
|
+
const previousRevision = this.pushDiagnostics.get(uri)?.revision ?? 0;
|
|
615
|
+
const document = await this.synchronizeDocument(filePath, languageId);
|
|
616
|
+
const candidates: Array<Promise<LspDocumentDiagnosticResult>> = [
|
|
617
|
+
this.waitForPushDiagnostics(document.uri, document.version, previousRevision, signal),
|
|
618
|
+
];
|
|
619
|
+
const registration = this.documentPullRegistration();
|
|
620
|
+
if (registration !== undefined) {
|
|
621
|
+
candidates.push(this.pullDocumentDiagnostics(document.uri, registration.identifier, signal));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
return await this.firstDiagnosticResult(candidates, signal);
|
|
626
|
+
} catch (cause) {
|
|
627
|
+
if (cause instanceof LspServerClientError) {
|
|
628
|
+
if (cause.kind === "cancelled") throw cause;
|
|
629
|
+
if (cause.kind === "timeout") return { status: "timeout", diagnostics: [] };
|
|
630
|
+
}
|
|
631
|
+
if (cause instanceof AggregateError) {
|
|
632
|
+
const cancellation = cause.errors.find(
|
|
633
|
+
(error) => error instanceof LspServerClientError && error.kind === "cancelled",
|
|
634
|
+
);
|
|
635
|
+
if (cancellation instanceof LspServerClientError) throw cancellation;
|
|
636
|
+
if (
|
|
637
|
+
cause.errors.length > 0 &&
|
|
638
|
+
cause.errors.every(
|
|
639
|
+
(error) => error instanceof LspServerClientError && error.kind === "timeout",
|
|
640
|
+
)
|
|
641
|
+
) {
|
|
642
|
+
return { status: "timeout", diagnostics: [] };
|
|
643
|
+
}
|
|
644
|
+
const clientError = cause.errors.find((error) => error instanceof LspServerClientError);
|
|
645
|
+
if (clientError instanceof LspServerClientError) throw clientError;
|
|
646
|
+
}
|
|
647
|
+
throw cause;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Pull workspace diagnostics when supported, otherwise return cached push diagnostics only. */
|
|
652
|
+
async workspaceDiagnostics(signal?: AbortSignal): Promise<LspWorkspaceDiagnosticResult> {
|
|
653
|
+
const registration = this.workspacePullRegistration();
|
|
654
|
+
if (registration === undefined) {
|
|
655
|
+
return {
|
|
656
|
+
status: "fresh",
|
|
657
|
+
source: "push_cache",
|
|
658
|
+
diagnosticsByUri: new Map(
|
|
659
|
+
[...this.pushDiagnostics]
|
|
660
|
+
.filter(([uri, state]) => {
|
|
661
|
+
const version = this.openDocuments.get(uri)?.version;
|
|
662
|
+
return (
|
|
663
|
+
state.version === undefined || (version !== undefined && state.version === version)
|
|
664
|
+
);
|
|
665
|
+
})
|
|
666
|
+
.map(([uri, state]) => [uri, state.diagnostics]),
|
|
667
|
+
),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
const report = await this.sendRequestWithBudget<WorkspaceDiagnosticReport>(
|
|
673
|
+
WorkspaceDiagnosticRequest.method,
|
|
674
|
+
{
|
|
675
|
+
identifier: registration.identifier,
|
|
676
|
+
previousResultIds: [...this.pullDiagnostics]
|
|
677
|
+
.filter(([, state]) => state.resultId !== undefined)
|
|
678
|
+
.map(([uri, state]) => ({ uri, value: state.resultId ?? "" })),
|
|
679
|
+
},
|
|
680
|
+
this.options.timeouts.diagnosticsMs,
|
|
681
|
+
WorkspaceDiagnosticRequest.method,
|
|
682
|
+
signal,
|
|
683
|
+
);
|
|
684
|
+
const diagnosticsByUri = this.acceptWorkspaceDiagnosticReport(report);
|
|
685
|
+
return { status: "fresh", source: "workspace_pull", diagnosticsByUri };
|
|
686
|
+
} catch (cause) {
|
|
687
|
+
if (cause instanceof LspServerClientError && cause.kind === "timeout") {
|
|
688
|
+
return {
|
|
689
|
+
status: "timeout",
|
|
690
|
+
diagnosticsByUri: new Map<string, never>(),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
throw cause;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Gracefully shut down within the configured budget, then terminate the process. */
|
|
698
|
+
async shutdown(): Promise<void> {
|
|
699
|
+
if (this.closed || this.closing) return;
|
|
700
|
+
this.closing = true;
|
|
701
|
+
try {
|
|
702
|
+
if (this.childProcess.exitCode === null && this.childProcess.signalCode === null) {
|
|
703
|
+
await this.sendRequestWithBudget<null>(
|
|
704
|
+
ShutdownRequest.method,
|
|
705
|
+
null,
|
|
706
|
+
this.options.timeouts.shutdownMs,
|
|
707
|
+
ShutdownRequest.method,
|
|
708
|
+
).catch(() => null);
|
|
709
|
+
await this.connection.sendNotification(ExitNotification.type).catch(() => undefined);
|
|
710
|
+
await this.waitForProcessExit(this.options.timeouts.shutdownMs).catch(() => undefined);
|
|
711
|
+
}
|
|
712
|
+
} finally {
|
|
713
|
+
await this.forceStop();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private bindProcessLifecycle(): void {
|
|
718
|
+
this.childProcess.stderr.on("data", (chunk: Buffer) => {
|
|
719
|
+
this.stderrTail = appendTail(this.stderrTail, chunk);
|
|
720
|
+
const snapshot = this.stderrTail;
|
|
721
|
+
this.stderrWrite = this.stderrWrite.then(() =>
|
|
722
|
+
writeFile(this.stderrPath, snapshot, { mode: 0o600 }),
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
this.childProcess.once("error", (cause) => {
|
|
726
|
+
this.markTerminalError(
|
|
727
|
+
new LspServerClientError(
|
|
728
|
+
"spawn",
|
|
729
|
+
this.serverId,
|
|
730
|
+
this.stderrPath,
|
|
731
|
+
"process failed to start",
|
|
732
|
+
{ cause },
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
this.childProcess.once("exit", (code, processSignal) => {
|
|
737
|
+
if (this.closed || this.closing) return;
|
|
738
|
+
this.markTerminalError(
|
|
739
|
+
new LspServerClientError(
|
|
740
|
+
"exit",
|
|
741
|
+
this.serverId,
|
|
742
|
+
this.stderrPath,
|
|
743
|
+
`process exited unexpectedly with ${processSignal ?? `code ${code ?? "unknown"}`}`,
|
|
744
|
+
),
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
this.connection.onError(([cause]) => {
|
|
748
|
+
this.markTerminalError(
|
|
749
|
+
new LspServerClientError(
|
|
750
|
+
"protocol",
|
|
751
|
+
this.serverId,
|
|
752
|
+
this.stderrPath,
|
|
753
|
+
"JSON-RPC connection failed",
|
|
754
|
+
{ cause },
|
|
755
|
+
),
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
this.connection.onClose(() => {
|
|
759
|
+
if (this.closed || this.closing) return;
|
|
760
|
+
this.markTerminalError(
|
|
761
|
+
new LspServerClientError(
|
|
762
|
+
"protocol",
|
|
763
|
+
this.serverId,
|
|
764
|
+
this.stderrPath,
|
|
765
|
+
"JSON-RPC connection closed unexpectedly",
|
|
766
|
+
),
|
|
767
|
+
);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private bindProtocolHandlers(): void {
|
|
772
|
+
this.connection.onNotification(PublishDiagnosticsNotification.type, (parameters) => {
|
|
773
|
+
this.diagnosticsRevision++;
|
|
774
|
+
const state: PushDiagnosticsState = {
|
|
775
|
+
diagnostics: parameters.diagnostics,
|
|
776
|
+
revision: this.diagnosticsRevision,
|
|
777
|
+
};
|
|
778
|
+
if (parameters.version !== undefined) {
|
|
779
|
+
const stateWithVersion: PushDiagnosticsState = {
|
|
780
|
+
...state,
|
|
781
|
+
version: parameters.version,
|
|
782
|
+
};
|
|
783
|
+
this.pushDiagnostics.set(parameters.uri, stateWithVersion);
|
|
784
|
+
} else {
|
|
785
|
+
this.pushDiagnostics.set(parameters.uri, state);
|
|
786
|
+
}
|
|
787
|
+
for (const notify of this.diagnosticWaiters.get(parameters.uri) ?? []) notify();
|
|
788
|
+
});
|
|
789
|
+
this.connection.onRequest(ConfigurationRequest.type, (parameters) =>
|
|
790
|
+
parameters.items.map((item) =>
|
|
791
|
+
configurationSectionValue(this.options.settings, item.section),
|
|
792
|
+
),
|
|
793
|
+
);
|
|
794
|
+
this.connection.onRequest(WorkspaceFoldersRequest.type, () => [
|
|
795
|
+
{ name: this.serverId, uri: pathToFileURL(this.rootPath).href },
|
|
796
|
+
]);
|
|
797
|
+
this.connection.onRequest(RegistrationRequest.type, (parameters) => {
|
|
798
|
+
for (const registration of parameters.registrations) {
|
|
799
|
+
this.dynamicRegistrations.set(registration.id, registration);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
this.connection.onRequest(UnregistrationRequest.type, (parameters) => {
|
|
803
|
+
for (const registration of parameters.unregisterations) {
|
|
804
|
+
this.removeDynamicRegistration(registration);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
this.connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
|
|
808
|
+
this.connection.onRequest(DiagnosticRefreshRequest.type, () => {
|
|
809
|
+
this.diagnosticsRefreshRevision++;
|
|
810
|
+
});
|
|
811
|
+
this.connection.onRequest(ApplyWorkspaceEditRequest.type, async (parameters) =>
|
|
812
|
+
this.rejectServerWorkspaceEdit(parameters),
|
|
813
|
+
);
|
|
814
|
+
this.connection.onRequest(ShowMessageRequest.type, (parameters) => {
|
|
815
|
+
this.rememberProtocolMessage(parameters.message);
|
|
816
|
+
return null;
|
|
817
|
+
});
|
|
818
|
+
this.connection.onNotification(LogMessageNotification.type, (parameters) => {
|
|
819
|
+
this.rememberProtocolMessage(parameters.message);
|
|
820
|
+
});
|
|
821
|
+
this.connection.onNotification(ShowMessageNotification.type, (parameters) => {
|
|
822
|
+
this.rememberProtocolMessage(parameters.message);
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private async initialize(): Promise<void> {
|
|
827
|
+
const rootUri = pathToFileURL(this.rootPath).href;
|
|
828
|
+
let result: InitializeResult;
|
|
829
|
+
try {
|
|
830
|
+
result = await this.sendRequestWithBudget<InitializeResult>(
|
|
831
|
+
InitializeRequest.method,
|
|
832
|
+
{
|
|
833
|
+
processId: process.pid,
|
|
834
|
+
rootUri,
|
|
835
|
+
workspaceFolders: [{ name: this.serverId, uri: rootUri }],
|
|
836
|
+
initializationOptions: this.options.initializationOptions,
|
|
837
|
+
capabilities: {
|
|
838
|
+
general: {
|
|
839
|
+
positionEncodings: [
|
|
840
|
+
PositionEncodingKind.UTF8,
|
|
841
|
+
PositionEncodingKind.UTF16,
|
|
842
|
+
PositionEncodingKind.UTF32,
|
|
843
|
+
],
|
|
844
|
+
},
|
|
845
|
+
window: { workDoneProgress: true },
|
|
846
|
+
workspace: {
|
|
847
|
+
applyEdit: true,
|
|
848
|
+
configuration: true,
|
|
849
|
+
workspaceFolders: true,
|
|
850
|
+
didChangeWatchedFiles: { dynamicRegistration: false },
|
|
851
|
+
diagnostics: { refreshSupport: true },
|
|
852
|
+
workspaceEdit: {
|
|
853
|
+
documentChanges: true,
|
|
854
|
+
resourceOperations: ["create", "rename", "delete"],
|
|
855
|
+
failureHandling: "undo",
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
textDocument: {
|
|
859
|
+
synchronization: { didOpen: true, didClose: true, didSave: true },
|
|
860
|
+
publishDiagnostics: { relatedInformation: true, versionSupport: true },
|
|
861
|
+
diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
|
|
862
|
+
completion: { dynamicRegistration: true },
|
|
863
|
+
hover: { dynamicRegistration: true },
|
|
864
|
+
signatureHelp: { dynamicRegistration: true },
|
|
865
|
+
declaration: { dynamicRegistration: true, linkSupport: true },
|
|
866
|
+
definition: { dynamicRegistration: true, linkSupport: true },
|
|
867
|
+
typeDefinition: { dynamicRegistration: true, linkSupport: true },
|
|
868
|
+
implementation: { dynamicRegistration: true, linkSupport: true },
|
|
869
|
+
references: { dynamicRegistration: true },
|
|
870
|
+
documentHighlight: { dynamicRegistration: true },
|
|
871
|
+
documentSymbol: {
|
|
872
|
+
dynamicRegistration: true,
|
|
873
|
+
hierarchicalDocumentSymbolSupport: true,
|
|
874
|
+
},
|
|
875
|
+
documentLink: { dynamicRegistration: true, tooltipSupport: true },
|
|
876
|
+
callHierarchy: { dynamicRegistration: true },
|
|
877
|
+
typeHierarchy: { dynamicRegistration: true },
|
|
878
|
+
selectionRange: { dynamicRegistration: true },
|
|
879
|
+
foldingRange: { dynamicRegistration: true },
|
|
880
|
+
codeLens: { dynamicRegistration: true },
|
|
881
|
+
inlayHint: { dynamicRegistration: true },
|
|
882
|
+
colorProvider: { dynamicRegistration: true },
|
|
883
|
+
formatting: { dynamicRegistration: true },
|
|
884
|
+
rangeFormatting: { dynamicRegistration: true },
|
|
885
|
+
onTypeFormatting: { dynamicRegistration: true },
|
|
886
|
+
rename: { dynamicRegistration: true, prepareSupport: true },
|
|
887
|
+
codeAction: { dynamicRegistration: true, isPreferredSupport: true },
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
this.options.timeouts.initializeMs,
|
|
892
|
+
InitializeRequest.method,
|
|
893
|
+
);
|
|
894
|
+
} catch (cause) {
|
|
895
|
+
if (cause instanceof LspServerClientError) throw cause;
|
|
896
|
+
throw new LspServerClientError(
|
|
897
|
+
"initialize",
|
|
898
|
+
this.serverId,
|
|
899
|
+
this.stderrPath,
|
|
900
|
+
"initialize request failed",
|
|
901
|
+
{ cause },
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this.capabilitiesValue = result.capabilities;
|
|
906
|
+
this.serverInfoValue = result.serverInfo;
|
|
907
|
+
this.positionEncodingValue = normalizeLspPositionEncoding(result.capabilities.positionEncoding);
|
|
908
|
+
this.textDocumentSyncKind = syncKindFromCapabilities(result.capabilities);
|
|
909
|
+
await this.connection.sendNotification(InitializedNotification.type, {});
|
|
910
|
+
await this.connection.sendNotification(DidChangeConfigurationNotification.type, {
|
|
911
|
+
settings: this.options.settings,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private async sendRequestWithBudget<TResult>(
|
|
916
|
+
method: string,
|
|
917
|
+
parameters: LSPAny,
|
|
918
|
+
budgetMs: number,
|
|
919
|
+
operation: string,
|
|
920
|
+
signal?: AbortSignal,
|
|
921
|
+
): Promise<TResult> {
|
|
922
|
+
this.throwIfUnavailable();
|
|
923
|
+
if (signal?.aborted === true) throw abortError(this.options);
|
|
924
|
+
|
|
925
|
+
const cancellation = new CancellationTokenSource();
|
|
926
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
927
|
+
let abortListener: (() => void) | undefined;
|
|
928
|
+
const request = this.connection.sendRequest<TResult>(method, parameters, cancellation.token);
|
|
929
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
930
|
+
timeout = setTimeout(() => {
|
|
931
|
+
cancellation.cancel();
|
|
932
|
+
reject(timeoutError(this.options, operation));
|
|
933
|
+
}, budgetMs);
|
|
934
|
+
timeout.unref();
|
|
935
|
+
});
|
|
936
|
+
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
937
|
+
if (signal === undefined) return;
|
|
938
|
+
abortListener = () => {
|
|
939
|
+
cancellation.cancel();
|
|
940
|
+
reject(abortError(this.options));
|
|
941
|
+
};
|
|
942
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
943
|
+
});
|
|
944
|
+
const terminalPromise = this.terminalErrorPromise.then((error) => {
|
|
945
|
+
throw error;
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
try {
|
|
949
|
+
return await Promise.race([request, timeoutPromise, abortPromise, terminalPromise]);
|
|
950
|
+
} catch (cause) {
|
|
951
|
+
if (cause instanceof LspServerClientError) throw cause;
|
|
952
|
+
throw new LspServerClientError(
|
|
953
|
+
"protocol",
|
|
954
|
+
this.serverId,
|
|
955
|
+
this.stderrPath,
|
|
956
|
+
`${operation} request failed`,
|
|
957
|
+
{ cause },
|
|
958
|
+
);
|
|
959
|
+
} finally {
|
|
960
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
961
|
+
if (signal !== undefined && abortListener !== undefined) {
|
|
962
|
+
signal.removeEventListener("abort", abortListener);
|
|
963
|
+
}
|
|
964
|
+
cancellation.dispose();
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private async waitForPushDiagnostics(
|
|
969
|
+
uri: string,
|
|
970
|
+
version: number,
|
|
971
|
+
previousRevision: number,
|
|
972
|
+
signal?: AbortSignal,
|
|
973
|
+
): Promise<LspDocumentDiagnosticResult> {
|
|
974
|
+
const deadline = Date.now() + this.options.timeouts.diagnosticsMs;
|
|
975
|
+
for (;;) {
|
|
976
|
+
const current = this.pushDiagnostics.get(uri);
|
|
977
|
+
if (
|
|
978
|
+
current !== undefined &&
|
|
979
|
+
current.revision > previousRevision &&
|
|
980
|
+
(current.version === undefined || current.version === version)
|
|
981
|
+
) {
|
|
982
|
+
return { status: "fresh", source: "push", diagnostics: current.diagnostics };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const remainingMs = deadline - Date.now();
|
|
986
|
+
if (remainingMs <= 0) throw timeoutError(this.options, "diagnostics");
|
|
987
|
+
let notifyWaiter: (() => void) | undefined;
|
|
988
|
+
const notification = new Promise<void>((resolveNotification) => {
|
|
989
|
+
notifyWaiter = resolveNotification;
|
|
990
|
+
const waiters = this.diagnosticWaiters.get(uri) ?? new Set();
|
|
991
|
+
waiters.add(resolveNotification);
|
|
992
|
+
this.diagnosticWaiters.set(uri, waiters);
|
|
993
|
+
});
|
|
994
|
+
try {
|
|
995
|
+
await this.raceBudget(notification, remainingMs, "diagnostics", signal);
|
|
996
|
+
} finally {
|
|
997
|
+
if (notifyWaiter !== undefined) {
|
|
998
|
+
const waiters = this.diagnosticWaiters.get(uri);
|
|
999
|
+
waiters?.delete(notifyWaiter);
|
|
1000
|
+
if (waiters?.size === 0) this.diagnosticWaiters.delete(uri);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private async pullDocumentDiagnostics(
|
|
1007
|
+
uri: string,
|
|
1008
|
+
identifier: string | undefined,
|
|
1009
|
+
signal?: AbortSignal,
|
|
1010
|
+
): Promise<LspDocumentDiagnosticResult> {
|
|
1011
|
+
const previous = this.pullDiagnostics.get(uri);
|
|
1012
|
+
const report = await this.sendRequestWithBudget<DocumentDiagnosticReport>(
|
|
1013
|
+
DocumentDiagnosticRequest.method,
|
|
1014
|
+
{
|
|
1015
|
+
textDocument: { uri },
|
|
1016
|
+
identifier,
|
|
1017
|
+
previousResultId: previous?.resultId,
|
|
1018
|
+
},
|
|
1019
|
+
this.options.timeouts.diagnosticsMs,
|
|
1020
|
+
DocumentDiagnosticRequest.method,
|
|
1021
|
+
signal,
|
|
1022
|
+
);
|
|
1023
|
+
if (report.kind === DocumentDiagnosticReportKind.Unchanged) {
|
|
1024
|
+
return {
|
|
1025
|
+
status: "fresh",
|
|
1026
|
+
source: "document_pull",
|
|
1027
|
+
diagnostics: previous?.diagnostics ?? [],
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
const state: PullDiagnosticsState = { diagnostics: report.items };
|
|
1031
|
+
this.pullDiagnostics.set(
|
|
1032
|
+
uri,
|
|
1033
|
+
report.resultId === undefined ? state : { ...state, resultId: report.resultId },
|
|
1034
|
+
);
|
|
1035
|
+
if (report.relatedDocuments !== undefined) {
|
|
1036
|
+
for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
|
|
1037
|
+
if (related.kind === DocumentDiagnosticReportKind.Unchanged) continue;
|
|
1038
|
+
const relatedState: PullDiagnosticsState = { diagnostics: related.items };
|
|
1039
|
+
this.pullDiagnostics.set(
|
|
1040
|
+
relatedUri,
|
|
1041
|
+
related.resultId === undefined
|
|
1042
|
+
? relatedState
|
|
1043
|
+
: { ...relatedState, resultId: related.resultId },
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return { status: "fresh", source: "document_pull", diagnostics: report.items };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private async firstDiagnosticResult(
|
|
1051
|
+
candidates: readonly Promise<LspDocumentDiagnosticResult>[],
|
|
1052
|
+
signal?: AbortSignal,
|
|
1053
|
+
): Promise<LspDocumentDiagnosticResult> {
|
|
1054
|
+
return this.raceBudget(
|
|
1055
|
+
Promise.any(candidates),
|
|
1056
|
+
this.options.timeouts.diagnosticsMs,
|
|
1057
|
+
"diagnostics",
|
|
1058
|
+
signal,
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private async raceBudget<TResult>(
|
|
1063
|
+
value: Promise<TResult>,
|
|
1064
|
+
budgetMs: number,
|
|
1065
|
+
operation: string,
|
|
1066
|
+
signal?: AbortSignal,
|
|
1067
|
+
): Promise<TResult> {
|
|
1068
|
+
if (signal?.aborted === true) throw abortError(this.options);
|
|
1069
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
1070
|
+
let abortListener: (() => void) | undefined;
|
|
1071
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1072
|
+
timeout = setTimeout(() => reject(timeoutError(this.options, operation)), budgetMs);
|
|
1073
|
+
timeout.unref();
|
|
1074
|
+
});
|
|
1075
|
+
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
1076
|
+
if (signal === undefined) return;
|
|
1077
|
+
abortListener = () => reject(abortError(this.options));
|
|
1078
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
1079
|
+
});
|
|
1080
|
+
try {
|
|
1081
|
+
return await Promise.race([value, timeoutPromise, abortPromise]);
|
|
1082
|
+
} finally {
|
|
1083
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
1084
|
+
if (signal !== undefined && abortListener !== undefined) {
|
|
1085
|
+
signal.removeEventListener("abort", abortListener);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private documentPullRegistration(): DynamicDiagnosticRegistration | undefined {
|
|
1091
|
+
const dynamic = [...this.dynamicRegistrations.values()].find(
|
|
1092
|
+
(registration) => registration.method === DocumentDiagnosticRequest.method,
|
|
1093
|
+
);
|
|
1094
|
+
if (dynamic !== undefined) return this.parseDiagnosticRegistration(dynamic);
|
|
1095
|
+
if (this.capabilitiesValue.diagnosticProvider === undefined) return undefined;
|
|
1096
|
+
const provider = this.capabilitiesValue.diagnosticProvider;
|
|
1097
|
+
const staticRegistration: DynamicDiagnosticRegistration = {
|
|
1098
|
+
workspaceDiagnostics: provider.workspaceDiagnostics === true,
|
|
1099
|
+
};
|
|
1100
|
+
return provider.identifier === undefined
|
|
1101
|
+
? staticRegistration
|
|
1102
|
+
: { ...staticRegistration, identifier: provider.identifier };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private workspacePullRegistration(): DynamicDiagnosticRegistration | undefined {
|
|
1106
|
+
const registration = this.documentPullRegistration();
|
|
1107
|
+
return registration?.workspaceDiagnostics === true ? registration : undefined;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private parseDiagnosticRegistration(registration: Registration): DynamicDiagnosticRegistration {
|
|
1111
|
+
const options = registration.registerOptions;
|
|
1112
|
+
if (!Value.Check(DynamicDiagnosticRegistrationSchema, options)) {
|
|
1113
|
+
return { workspaceDiagnostics: false };
|
|
1114
|
+
}
|
|
1115
|
+
const registrationOptions: DynamicDiagnosticRegistration = {
|
|
1116
|
+
workspaceDiagnostics: options.workspaceDiagnostics === true,
|
|
1117
|
+
};
|
|
1118
|
+
return options.identifier === undefined
|
|
1119
|
+
? registrationOptions
|
|
1120
|
+
: { ...registrationOptions, identifier: options.identifier };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private acceptWorkspaceDiagnosticReport(
|
|
1124
|
+
report: WorkspaceDiagnosticReport,
|
|
1125
|
+
): ReadonlyMap<string, readonly Diagnostic[]> {
|
|
1126
|
+
const diagnosticsByUri = new Map<string, readonly Diagnostic[]>();
|
|
1127
|
+
for (const item of report.items) {
|
|
1128
|
+
if (item.kind === DocumentDiagnosticReportKind.Unchanged) {
|
|
1129
|
+
diagnosticsByUri.set(item.uri, this.pullDiagnostics.get(item.uri)?.diagnostics ?? []);
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const state: PullDiagnosticsState = { diagnostics: item.items };
|
|
1133
|
+
this.pullDiagnostics.set(
|
|
1134
|
+
item.uri,
|
|
1135
|
+
item.resultId === undefined ? state : { ...state, resultId: item.resultId },
|
|
1136
|
+
);
|
|
1137
|
+
diagnosticsByUri.set(item.uri, item.items);
|
|
1138
|
+
}
|
|
1139
|
+
return diagnosticsByUri;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private removeDynamicRegistration(unregistration: Unregistration): void {
|
|
1143
|
+
const registration = this.dynamicRegistrations.get(unregistration.id);
|
|
1144
|
+
if (registration?.method === unregistration.method) {
|
|
1145
|
+
this.dynamicRegistrations.delete(unregistration.id);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
private async rejectServerWorkspaceEdit(
|
|
1150
|
+
parameters: ApplyWorkspaceEditParams,
|
|
1151
|
+
): Promise<ApplyWorkspaceEditResult> {
|
|
1152
|
+
if (this.options.onWorkspaceEdit === undefined) {
|
|
1153
|
+
return { applied: false, failureReason: "Pi LSP: workspace edit requires a preview" };
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const previewId = await this.options.onWorkspaceEdit(parameters.edit);
|
|
1157
|
+
return {
|
|
1158
|
+
applied: false,
|
|
1159
|
+
failureReason: `Pi LSP: workspace edit captured as preview ${previewId}`,
|
|
1160
|
+
};
|
|
1161
|
+
} catch (cause) {
|
|
1162
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1163
|
+
return {
|
|
1164
|
+
applied: false,
|
|
1165
|
+
failureReason: `Pi LSP: workspace edit preview rejected: ${message}`,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private rememberProtocolMessage(message: string): void {
|
|
1171
|
+
this.protocolMessages.push(message);
|
|
1172
|
+
if (this.protocolMessages.length > 100) this.protocolMessages.shift();
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
private async evictOldDocuments(): Promise<void> {
|
|
1176
|
+
while (this.openDocuments.size > MAX_OPEN_DOCUMENTS) {
|
|
1177
|
+
const oldestUri = this.openDocuments.keys().next().value;
|
|
1178
|
+
if (oldestUri === undefined) return;
|
|
1179
|
+
this.openDocuments.delete(oldestUri);
|
|
1180
|
+
await this.connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
1181
|
+
textDocument: { uri: oldestUri },
|
|
1182
|
+
});
|
|
1183
|
+
this.pushDiagnostics.delete(oldestUri);
|
|
1184
|
+
this.pullDiagnostics.delete(oldestUri);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private markTerminalError(error: LspServerClientError): void {
|
|
1189
|
+
if (this.terminalError !== undefined) return;
|
|
1190
|
+
this.terminalError = error;
|
|
1191
|
+
this.resolveTerminalError?.(error);
|
|
1192
|
+
try {
|
|
1193
|
+
this.options.onUnavailable?.(error);
|
|
1194
|
+
} catch {
|
|
1195
|
+
// The client failure remains authoritative when an outer status callback fails.
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private throwIfUnavailable(): void {
|
|
1200
|
+
if (this.terminalError !== undefined) throw this.terminalError;
|
|
1201
|
+
if (this.closed) {
|
|
1202
|
+
throw new LspServerClientError("exit", this.serverId, this.stderrPath, "client is shut down");
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
private async waitForProcessExit(timeoutMs: number): Promise<void> {
|
|
1207
|
+
if (this.childProcess.exitCode !== null || this.childProcess.signalCode !== null) return;
|
|
1208
|
+
await this.raceBudget(
|
|
1209
|
+
new Promise<void>((resolveExit) => this.childProcess.once("exit", () => resolveExit())),
|
|
1210
|
+
timeoutMs,
|
|
1211
|
+
"process exit",
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private async forceStop(): Promise<void> {
|
|
1216
|
+
this.closed = true;
|
|
1217
|
+
if (this.childProcess.exitCode === null && this.childProcess.signalCode === null) {
|
|
1218
|
+
this.childProcess.kill();
|
|
1219
|
+
await new Promise<void>((resolveExit) => {
|
|
1220
|
+
const timeout = setTimeout(
|
|
1221
|
+
() => {
|
|
1222
|
+
this.childProcess.kill("SIGKILL");
|
|
1223
|
+
resolveExit();
|
|
1224
|
+
},
|
|
1225
|
+
Math.min(this.options.timeouts.shutdownMs, 1000),
|
|
1226
|
+
);
|
|
1227
|
+
timeout.unref();
|
|
1228
|
+
this.childProcess.once("exit", () => {
|
|
1229
|
+
clearTimeout(timeout);
|
|
1230
|
+
resolveExit();
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
this.connection.dispose();
|
|
1235
|
+
await this.stderrWrite.catch(() => undefined);
|
|
1236
|
+
}
|
|
1237
|
+
}
|