@kerebron/extension-lsp 0.4.28 → 0.4.29
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/esm/DiagnosticPlugin.js +1 -0
- package/esm/DiagnosticPlugin.js.map +1 -0
- package/esm/ExtensionLsp.js +1 -0
- package/esm/ExtensionLsp.js.map +1 -0
- package/esm/LSPClient.js +1 -0
- package/esm/LSPClient.js.map +1 -0
- package/esm/LspWebSocketTransport.js +1 -0
- package/esm/LspWebSocketTransport.js.map +1 -0
- package/esm/computeIncrementalChanges.js +1 -0
- package/esm/computeIncrementalChanges.js.map +1 -0
- package/esm/createLspAutocomplete.js +1 -0
- package/esm/createLspAutocomplete.js.map +1 -0
- package/esm/mod.js +1 -0
- package/esm/mod.js.map +1 -0
- package/esm/types.js +1 -0
- package/esm/types.js.map +1 -0
- package/esm/workspace.js +1 -0
- package/esm/workspace.js.map +1 -0
- package/package.json +8 -4
- package/src/DiagnosticPlugin.ts +196 -0
- package/src/ExtensionLsp.ts +118 -0
- package/src/LSPClient.ts +485 -0
- package/src/LspWebSocketTransport.ts +114 -0
- package/src/computeIncrementalChanges.ts +101 -0
- package/src/createLspAutocomplete.ts +95 -0
- package/src/mod.ts +3 -0
- package/src/types.ts +14 -0
- package/src/workspace.ts +192 -0
package/src/LSPClient.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import type * as lsp from 'vscode-languageserver-protocol';
|
|
2
|
+
import {
|
|
3
|
+
MessageType,
|
|
4
|
+
TextDocumentSyncKind,
|
|
5
|
+
} from 'vscode-languageserver-protocol';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DefaultWorkspace,
|
|
9
|
+
LspSource,
|
|
10
|
+
Workspace,
|
|
11
|
+
WorkspaceFile,
|
|
12
|
+
} from './workspace.js';
|
|
13
|
+
|
|
14
|
+
const defaultNotificationHandlers: {
|
|
15
|
+
[method: string]: (client: LSPClient, params: any) => void;
|
|
16
|
+
} = {
|
|
17
|
+
'window/logMessage': (client, params: lsp.LogMessageParams) => {
|
|
18
|
+
if (params.type == MessageType.Error) {
|
|
19
|
+
console.error('[lsp] ' + params.message);
|
|
20
|
+
} else if (params.type == MessageType.Warning) {
|
|
21
|
+
console.warn('[lsp] ' + params.message);
|
|
22
|
+
} else if (params.type == MessageType.Info) {
|
|
23
|
+
console.info('[lsp] ' + params.message);
|
|
24
|
+
} else if (params.type == MessageType.Log) {
|
|
25
|
+
console.log('[lsp] ' + params.message);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
'window/showMessage': (client, params: lsp.ShowMessageParams) => {
|
|
29
|
+
if (params.type > MessageType.Info) return;
|
|
30
|
+
for (const f of client.workspace.files) {
|
|
31
|
+
const ui = f.getUi();
|
|
32
|
+
if (!ui) continue;
|
|
33
|
+
ui.showMessage(params.message);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Transport = {
|
|
39
|
+
connect(): void;
|
|
40
|
+
disconnect(): void;
|
|
41
|
+
send(message: string): void;
|
|
42
|
+
addEventListener(
|
|
43
|
+
type: string,
|
|
44
|
+
listener: EventListenerOrEventListenerObject,
|
|
45
|
+
options?: boolean | AddEventListenerOptions,
|
|
46
|
+
): void;
|
|
47
|
+
removeEventListener(
|
|
48
|
+
type: string,
|
|
49
|
+
callback: EventListenerOrEventListenerObject | null,
|
|
50
|
+
options?: EventListenerOptions | boolean,
|
|
51
|
+
): void;
|
|
52
|
+
isConnected(): boolean;
|
|
53
|
+
isInitialized(): boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
class Request<Result> {
|
|
57
|
+
declare resolve: (result: Result) => void;
|
|
58
|
+
declare reject: (error: any) => void;
|
|
59
|
+
promise: Promise<Result>;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
readonly id: number,
|
|
63
|
+
readonly params: any,
|
|
64
|
+
readonly timeout: ReturnType<typeof setTimeout>,
|
|
65
|
+
) {
|
|
66
|
+
this.promise = new Promise((resolve, reject) => {
|
|
67
|
+
this.resolve = resolve;
|
|
68
|
+
this.reject = reject;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const clientCapabilities: lsp.ClientCapabilities = {
|
|
74
|
+
general: {
|
|
75
|
+
markdown: {
|
|
76
|
+
parser: 'marked',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
textDocument: {
|
|
80
|
+
publishDiagnostics: { versionSupport: true },
|
|
81
|
+
completion: {
|
|
82
|
+
completionItem: {
|
|
83
|
+
snippetSupport: true,
|
|
84
|
+
documentationFormat: ['plaintext', 'markdown'],
|
|
85
|
+
insertReplaceSupport: false,
|
|
86
|
+
},
|
|
87
|
+
completionList: {
|
|
88
|
+
itemDefaults: ['commitCharacters', 'editRange', 'insertTextFormat'],
|
|
89
|
+
},
|
|
90
|
+
completionItemKind: { valueSet: [] },
|
|
91
|
+
contextSupport: true,
|
|
92
|
+
},
|
|
93
|
+
hover: {
|
|
94
|
+
contentFormat: ['markdown', 'plaintext'],
|
|
95
|
+
},
|
|
96
|
+
// formatting: {},
|
|
97
|
+
// rename: {},
|
|
98
|
+
signatureHelp: {
|
|
99
|
+
contextSupport: true,
|
|
100
|
+
signatureInformation: {
|
|
101
|
+
documentationFormat: ['markdown', 'plaintext'],
|
|
102
|
+
parameterInformation: { labelOffsetSupport: true },
|
|
103
|
+
activeParameterSupport: true,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
definition: {},
|
|
107
|
+
declaration: {},
|
|
108
|
+
implementation: {},
|
|
109
|
+
typeDefinition: {},
|
|
110
|
+
references: {},
|
|
111
|
+
diagnostic: {},
|
|
112
|
+
},
|
|
113
|
+
window: {
|
|
114
|
+
showMessage: {},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/// Configuration options that can be passed to the LSP client.
|
|
119
|
+
export type LSPClientConfig = {
|
|
120
|
+
/// The project root URI passed to the server, when necessary.
|
|
121
|
+
rootUri?: string;
|
|
122
|
+
/// An optional function to create a
|
|
123
|
+
/// [workspace](#lsp-client.Workspace) object for the client to use.
|
|
124
|
+
/// When not given, this will default to a simple workspace that
|
|
125
|
+
/// only opens files that have an active editor, and only allows one
|
|
126
|
+
/// editor per file.
|
|
127
|
+
workspace?: (client: LSPClient) => Workspace;
|
|
128
|
+
/// The amount of milliseconds after which requests are
|
|
129
|
+
/// automatically timed out. Defaults to 3000.
|
|
130
|
+
timeout?: number;
|
|
131
|
+
/// LSP servers can send Markdown code, which the client must render
|
|
132
|
+
/// and display as HTML. Markdown can contain arbitrary HTML and is
|
|
133
|
+
/// thus a potential channel for cross-site scripting attacks, if
|
|
134
|
+
/// someone is able to compromise your LSP server or your connection
|
|
135
|
+
/// to it. You can pass an HTML sanitizer here to strip out
|
|
136
|
+
/// suspicious HTML structure.
|
|
137
|
+
sanitizeHTML?: (html: string) => string;
|
|
138
|
+
/// When no handler is found for a notification, it will be passed
|
|
139
|
+
/// to this function, if given.
|
|
140
|
+
unhandledNotification?: (
|
|
141
|
+
client: LSPClient,
|
|
142
|
+
method: string,
|
|
143
|
+
params: any,
|
|
144
|
+
) => void;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export class LSPError extends Error {
|
|
148
|
+
isTimeout = false;
|
|
149
|
+
readonly isLSP = true;
|
|
150
|
+
|
|
151
|
+
static createTimeout() {
|
|
152
|
+
const err = new LSPError('Request timed out');
|
|
153
|
+
err.isTimeout = true;
|
|
154
|
+
return err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class LSPClient extends EventTarget {
|
|
159
|
+
sources: Record<string, LspSource> = {};
|
|
160
|
+
|
|
161
|
+
workspace: Workspace;
|
|
162
|
+
private nextReqID = 0;
|
|
163
|
+
private requests: Request<any>[] = [];
|
|
164
|
+
|
|
165
|
+
serverCapabilities: lsp.ServerCapabilities | null = null;
|
|
166
|
+
public supportSync: TextDocumentSyncKind = TextDocumentSyncKind.None;
|
|
167
|
+
|
|
168
|
+
private readonly timeout: number;
|
|
169
|
+
private initializing: ReturnType<typeof setInterval> | undefined;
|
|
170
|
+
|
|
171
|
+
private readonly receiveListener: EventListenerOrEventListenerObject;
|
|
172
|
+
active: boolean = false;
|
|
173
|
+
|
|
174
|
+
constructor(
|
|
175
|
+
private readonly transport: Transport,
|
|
176
|
+
readonly config: LSPClientConfig = {},
|
|
177
|
+
) {
|
|
178
|
+
super();
|
|
179
|
+
|
|
180
|
+
this.timeout = config.timeout ?? 3000;
|
|
181
|
+
|
|
182
|
+
this.receiveListener = (event) => this.receiveMessage(event);
|
|
183
|
+
|
|
184
|
+
transport.addEventListener('message', this.receiveListener);
|
|
185
|
+
transport.addEventListener('initialized', () => {
|
|
186
|
+
try {
|
|
187
|
+
console.info('LSP initialized');
|
|
188
|
+
this.onInitialized();
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
if (err.isLSP) {
|
|
191
|
+
console.error(
|
|
192
|
+
'Timeout as client.onConnected()',
|
|
193
|
+
err.message,
|
|
194
|
+
this.onInitialized,
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
transport.addEventListener('open', () => {
|
|
202
|
+
this.startInitializing();
|
|
203
|
+
});
|
|
204
|
+
transport.addEventListener('close', (event) => {
|
|
205
|
+
this.active = false;
|
|
206
|
+
this.serverCapabilities = null;
|
|
207
|
+
this.dispatchEvent(new CloseEvent('close'));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
this.workspace = config.workspace
|
|
211
|
+
? config.workspace(this)
|
|
212
|
+
: new DefaultWorkspace(this);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
startInitializing() {
|
|
216
|
+
if (this.initializing) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.initializing = setInterval(async () => {
|
|
220
|
+
const capabilities = clientCapabilities;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const resp = await this.requestInner<
|
|
224
|
+
lsp.InitializeParams,
|
|
225
|
+
lsp.InitializeResult
|
|
226
|
+
>(
|
|
227
|
+
'initialize',
|
|
228
|
+
{
|
|
229
|
+
processId: null,
|
|
230
|
+
clientInfo: { name: '@kerebron/lsp-client' },
|
|
231
|
+
rootUri: this.config.rootUri || null,
|
|
232
|
+
capabilities,
|
|
233
|
+
},
|
|
234
|
+
).promise;
|
|
235
|
+
|
|
236
|
+
this.stopInitializing();
|
|
237
|
+
|
|
238
|
+
this.serverCapabilities = resp.capabilities;
|
|
239
|
+
const sync = this.serverCapabilities.textDocumentSync;
|
|
240
|
+
this.supportSync = TextDocumentSyncKind.None;
|
|
241
|
+
if (sync) {
|
|
242
|
+
this.supportSync = typeof sync == 'number'
|
|
243
|
+
? sync
|
|
244
|
+
: sync.change ?? TextDocumentSyncKind.None;
|
|
245
|
+
}
|
|
246
|
+
// deno-lint-ignore no-empty
|
|
247
|
+
} catch (ignoreConnectErrors) {}
|
|
248
|
+
}, this.timeout);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
stopInitializing() {
|
|
252
|
+
if (this.initializing) {
|
|
253
|
+
clearInterval(this.initializing);
|
|
254
|
+
this.initializing = undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async restart() {
|
|
259
|
+
this.active = true;
|
|
260
|
+
if (!this.transport.isConnected()) {
|
|
261
|
+
this.transport.connect();
|
|
262
|
+
} else {
|
|
263
|
+
this.startInitializing();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
onInitialized() {
|
|
268
|
+
this.transport.send(
|
|
269
|
+
JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
this.workspace.connected();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
connect(uri: string, source: LspSource) {
|
|
276
|
+
if (this.sources[uri] && this.sources[uri] !== source) {
|
|
277
|
+
throw new Error(`Source for ${uri} already connected`);
|
|
278
|
+
}
|
|
279
|
+
this.sources[uri] = source;
|
|
280
|
+
this.active = true;
|
|
281
|
+
if (!this.transport.isConnected()) {
|
|
282
|
+
this.transport.connect();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
disconnect(uri: string) {
|
|
287
|
+
delete this.sources[uri];
|
|
288
|
+
this.workspace.closeFile(uri);
|
|
289
|
+
if (Object.keys(this.sources).length === 0) {
|
|
290
|
+
this.active = false;
|
|
291
|
+
this.serverCapabilities = null;
|
|
292
|
+
this.transport.removeEventListener('data', this.receiveListener);
|
|
293
|
+
this.workspace.disconnected();
|
|
294
|
+
this.dispatchEvent(new CloseEvent('close'));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Send a `textDocument/didOpen` notification to the server.
|
|
299
|
+
async didOpen(file: WorkspaceFile) {
|
|
300
|
+
if (!this.transport.isInitialized()) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
await this.notification<lsp.DidOpenTextDocumentParams>(
|
|
304
|
+
'textDocument/didOpen',
|
|
305
|
+
{
|
|
306
|
+
textDocument: {
|
|
307
|
+
uri: file.uri,
|
|
308
|
+
languageId: file.languageId,
|
|
309
|
+
text: file.content,
|
|
310
|
+
version: file.version,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Send a `textDocument/didClose` notification to the server.
|
|
318
|
+
didClose(uri: string) {
|
|
319
|
+
if (!this.transport.isInitialized()) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.notification<lsp.DidCloseTextDocumentParams>('textDocument/didClose', {
|
|
323
|
+
textDocument: { uri },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private receiveMessage(event: Event) {
|
|
328
|
+
const msg = (event as MessageEvent).data;
|
|
329
|
+
|
|
330
|
+
const value = JSON.parse(msg) as
|
|
331
|
+
| lsp.ResponseMessage
|
|
332
|
+
| lsp.NotificationMessage
|
|
333
|
+
| lsp.RequestMessage;
|
|
334
|
+
|
|
335
|
+
if ('id' in value && !('method' in value)) {
|
|
336
|
+
const index = this.requests.findIndex((r) => r.id == value.id);
|
|
337
|
+
if (index < 0) {
|
|
338
|
+
console.warn(
|
|
339
|
+
`[lsp] Received a response for non-existent request ${value.id}`,
|
|
340
|
+
);
|
|
341
|
+
} else {
|
|
342
|
+
const req = this.requests[index];
|
|
343
|
+
clearTimeout(req.timeout);
|
|
344
|
+
this.requests.splice(index, 1);
|
|
345
|
+
if (value.error) req.reject(value.error);
|
|
346
|
+
else req.resolve(value.result);
|
|
347
|
+
}
|
|
348
|
+
} else if (!('id' in value)) {
|
|
349
|
+
const event = new CustomEvent(value.method, {
|
|
350
|
+
detail: { params: value.params },
|
|
351
|
+
});
|
|
352
|
+
if (this.dispatchEvent(event)) {
|
|
353
|
+
if (this.config.unhandledNotification) {
|
|
354
|
+
this.config.unhandledNotification(this, value.method, value.params);
|
|
355
|
+
} else {
|
|
356
|
+
if (defaultNotificationHandlers[value.method]) {
|
|
357
|
+
defaultNotificationHandlers[value.method](this, value.params);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
const resp: lsp.ResponseMessage = {
|
|
363
|
+
jsonrpc: '2.0',
|
|
364
|
+
id: value.id,
|
|
365
|
+
error: {
|
|
366
|
+
code: -32601, /* MethodNotFound */
|
|
367
|
+
message: 'Method not implemented',
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
this.transport.send(JSON.stringify(resp));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async request<Params, Result>(
|
|
375
|
+
method: string,
|
|
376
|
+
params: Params,
|
|
377
|
+
): Promise<Result> {
|
|
378
|
+
if (!this.transport.isConnected()) {
|
|
379
|
+
if (this.active) {
|
|
380
|
+
this.transport.connect();
|
|
381
|
+
}
|
|
382
|
+
throw new LSPError('Not connected');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const retVal = await this.requestInner<Params, Result>(method, params)
|
|
386
|
+
.promise;
|
|
387
|
+
return retVal;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private requestInner<Params, Result>(
|
|
391
|
+
method: string,
|
|
392
|
+
params: Params,
|
|
393
|
+
mapped = false,
|
|
394
|
+
): Request<Result> {
|
|
395
|
+
if (!this.transport) {
|
|
396
|
+
throw new Error('No transport');
|
|
397
|
+
}
|
|
398
|
+
if (!this.transport.isConnected()) {
|
|
399
|
+
if (this.active) {
|
|
400
|
+
this.transport.connect();
|
|
401
|
+
}
|
|
402
|
+
throw new Error('Transport not connected');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const id = ++this.nextReqID,
|
|
406
|
+
data: lsp.RequestMessage = {
|
|
407
|
+
jsonrpc: '2.0',
|
|
408
|
+
id,
|
|
409
|
+
method,
|
|
410
|
+
params: params as any,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const req = new Request<Result>(
|
|
414
|
+
id,
|
|
415
|
+
params,
|
|
416
|
+
setTimeout(
|
|
417
|
+
() => this.timeoutRequest(req, method, id, params),
|
|
418
|
+
this.timeout,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
if (!this.transport) {
|
|
424
|
+
throw new LSPError('No transport');
|
|
425
|
+
}
|
|
426
|
+
this.transport.send(JSON.stringify(data));
|
|
427
|
+
this.requests.push(req);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
console.error(e);
|
|
430
|
+
clearTimeout(req.timeout);
|
|
431
|
+
req.reject(e);
|
|
432
|
+
}
|
|
433
|
+
return req;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async notification<Params>(method: string, params: Params): Promise<boolean> {
|
|
437
|
+
if (!this.transport) return false;
|
|
438
|
+
if (!this.transport.isConnected()) {
|
|
439
|
+
if (this.active) {
|
|
440
|
+
this.transport.connect();
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (!this.transport.isInitialized()) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
const data: lsp.NotificationMessage = {
|
|
448
|
+
jsonrpc: '2.0',
|
|
449
|
+
method,
|
|
450
|
+
params: params as any,
|
|
451
|
+
};
|
|
452
|
+
this.transport.send(JSON.stringify(data));
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
cancelRequest(params: any) {
|
|
457
|
+
const found = this.requests.find((r) => r.params === params);
|
|
458
|
+
if (found) {
|
|
459
|
+
this.notification('$/cancelRequest', found.id);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
hasCapability(name: keyof lsp.ServerCapabilities) {
|
|
464
|
+
return this.serverCapabilities ? !!this.serverCapabilities[name] : null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
sync() {
|
|
468
|
+
this.workspace.syncFiles();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private timeoutRequest<T>(
|
|
472
|
+
req: Request<T>,
|
|
473
|
+
method: string,
|
|
474
|
+
id: number,
|
|
475
|
+
params: any,
|
|
476
|
+
) {
|
|
477
|
+
console.error('this.timeoutRequest', this.timeout, method, id, params);
|
|
478
|
+
|
|
479
|
+
const index = this.requests.indexOf(req);
|
|
480
|
+
if (index > -1) {
|
|
481
|
+
req.reject(LSPError.createTimeout());
|
|
482
|
+
this.requests.splice(index, 1);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type Transport } from './LSPClient.js';
|
|
2
|
+
|
|
3
|
+
function shouldReconnectOnCode(code: number) {
|
|
4
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
|
5
|
+
// Reconnect on server going away (1001), but NOT on normal close (1000)
|
|
6
|
+
const reconnectCodes = [1001, 1005, 1006, 1011, 1012, 1013];
|
|
7
|
+
return reconnectCodes.includes(code);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class LspWebSocketTransport extends EventTarget implements Transport {
|
|
11
|
+
socket: WebSocket | undefined;
|
|
12
|
+
private reconnectAttempts = 0;
|
|
13
|
+
private maxAttempts = 10;
|
|
14
|
+
private readonly baseDelay = 1000;
|
|
15
|
+
isConnecting: boolean = false;
|
|
16
|
+
initialized = false;
|
|
17
|
+
|
|
18
|
+
constructor(public readonly uri: string) {
|
|
19
|
+
super();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
isConnected() {
|
|
23
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isInitialized() {
|
|
27
|
+
return this.initialized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect() {
|
|
31
|
+
if (this.isConnecting) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.isConnecting = true;
|
|
35
|
+
this.socket?.close();
|
|
36
|
+
const socket = new WebSocket(this.uri);
|
|
37
|
+
this.bindEvents(socket);
|
|
38
|
+
this.socket = socket;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
disconnect(): void {
|
|
42
|
+
console.info('LSP transport disconnect()');
|
|
43
|
+
this.socket?.close();
|
|
44
|
+
this.socket = undefined;
|
|
45
|
+
this.initialized = false;
|
|
46
|
+
this.reconnectAttempts = 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
bindEvents(socket: WebSocket) {
|
|
50
|
+
socket.addEventListener('message', (event) => {
|
|
51
|
+
try {
|
|
52
|
+
const json = JSON.parse(event.data);
|
|
53
|
+
if (json?.result?.capabilities) {
|
|
54
|
+
this.reconnectAttempts = 0;
|
|
55
|
+
this.initialized = true;
|
|
56
|
+
this.dispatchEvent(new Event('initialized'));
|
|
57
|
+
}
|
|
58
|
+
// deno-lint-ignore no-empty
|
|
59
|
+
} catch (ignoredError) {}
|
|
60
|
+
this.dispatchEvent(new MessageEvent('message', { data: event.data }));
|
|
61
|
+
});
|
|
62
|
+
socket.addEventListener('open', (event) => {
|
|
63
|
+
this.isConnecting = false;
|
|
64
|
+
this.dispatchEvent(new CustomEvent('open'));
|
|
65
|
+
});
|
|
66
|
+
socket.addEventListener('error', (event) => {
|
|
67
|
+
console.error(event);
|
|
68
|
+
this.dispatchEvent(event);
|
|
69
|
+
});
|
|
70
|
+
socket.addEventListener('close', (event) => {
|
|
71
|
+
this.isConnecting = false;
|
|
72
|
+
this.dispatchEvent(
|
|
73
|
+
new CloseEvent('close', {
|
|
74
|
+
code: event.code,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
this.socket = undefined;
|
|
78
|
+
if (!event.wasClean || shouldReconnectOnCode(event.code)) {
|
|
79
|
+
this.scheduleReconnect();
|
|
80
|
+
} else {
|
|
81
|
+
console.info('Clean close — no reconnect');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
scheduleReconnect() {
|
|
87
|
+
if (this.reconnectAttempts >= this.maxAttempts) {
|
|
88
|
+
console.error('Max reconnect attempts reached');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
|
|
93
|
+
this.reconnectAttempts++;
|
|
94
|
+
|
|
95
|
+
console.info(
|
|
96
|
+
`Reconnecting in ${delay}ms... (attempt ${this.reconnectAttempts})`,
|
|
97
|
+
);
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
this.connect();
|
|
100
|
+
}, delay);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
send(message: string): void {
|
|
104
|
+
if (!this.socket) {
|
|
105
|
+
console.warn('Socket disconnected');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.socket.readyState === WebSocket.OPEN) {
|
|
109
|
+
this.socket.send(message);
|
|
110
|
+
} else {
|
|
111
|
+
console.warn('WebSocket not open: ' + this.socket.readyState);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as lsp from 'vscode-languageserver-protocol';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compares two strings and computes the minimal set of text changes
|
|
5
|
+
* using a diff-based approach (simple line-by-line + character diff for simplicity).
|
|
6
|
+
* Returns an array of TextEdit-like changes that transform `previous` into `current`.
|
|
7
|
+
*/
|
|
8
|
+
export function computeIncrementalChanges(
|
|
9
|
+
previous: string,
|
|
10
|
+
current: string,
|
|
11
|
+
): lsp.TextDocumentContentChangeEvent[] {
|
|
12
|
+
if (previous.length === 0) {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
text: current,
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const prevLines = previous.split(/\r\n|\r|\n/);
|
|
21
|
+
const currLines = current.split(/\r\n|\r|\n/);
|
|
22
|
+
|
|
23
|
+
const changes: lsp.TextDocumentContentChangeEvent[] = [];
|
|
24
|
+
let startLine = 0;
|
|
25
|
+
let insertedText = '';
|
|
26
|
+
|
|
27
|
+
// Find common prefix
|
|
28
|
+
while (startLine < prevLines.length && startLine < currLines.length) {
|
|
29
|
+
if (prevLines[startLine] === currLines[startLine]) {
|
|
30
|
+
startLine++;
|
|
31
|
+
} else {
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If entire document is the same
|
|
37
|
+
if (
|
|
38
|
+
startLine === prevLines.length &&
|
|
39
|
+
startLine === currLines.length
|
|
40
|
+
) {
|
|
41
|
+
return changes; // No changes
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Find common suffix starting from the end
|
|
45
|
+
let endLinePrev = prevLines.length - 1;
|
|
46
|
+
let endLineCurr = currLines.length - 1;
|
|
47
|
+
while (
|
|
48
|
+
endLinePrev >= startLine &&
|
|
49
|
+
endLineCurr >= startLine &&
|
|
50
|
+
prevLines[endLinePrev] === currLines[endLineCurr]
|
|
51
|
+
) {
|
|
52
|
+
endLinePrev--;
|
|
53
|
+
endLineCurr--;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Region to replace: from startLine to endLinePrev (inclusive)
|
|
57
|
+
const replaceStart: lsp.Position = { line: startLine, character: 0 };
|
|
58
|
+
let replaceEnd: lsp.Position;
|
|
59
|
+
|
|
60
|
+
if (endLinePrev >= startLine) {
|
|
61
|
+
const lastDeletedLine = prevLines[endLinePrev];
|
|
62
|
+
replaceEnd = {
|
|
63
|
+
line: endLinePrev,
|
|
64
|
+
character: lastDeletedLine.length,
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
// Deletion ends at the start of the next line
|
|
68
|
+
replaceEnd = { line: startLine, character: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build inserted text: lines from startLine to endLineCurr
|
|
72
|
+
const insertedLines = currLines.slice(startLine, endLineCurr + 1);
|
|
73
|
+
insertedText = insertedLines.join('\n');
|
|
74
|
+
|
|
75
|
+
// If we're inserting at the end of the file, adjust range
|
|
76
|
+
if (startLine === prevLines.length) {
|
|
77
|
+
// Inserting after last line
|
|
78
|
+
replaceEnd = {
|
|
79
|
+
line: prevLines.length - 1,
|
|
80
|
+
character: prevLines[prevLines.length - 1].length,
|
|
81
|
+
};
|
|
82
|
+
if (insertedLines.length === 0) {
|
|
83
|
+
// Inserting empty at EOF
|
|
84
|
+
insertedText = '\n';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Create the range for deletion
|
|
89
|
+
const range: lsp.Range = {
|
|
90
|
+
start: replaceStart,
|
|
91
|
+
end: replaceEnd,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Push the incremental change
|
|
95
|
+
changes.push({
|
|
96
|
+
range,
|
|
97
|
+
text: insertedText,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return changes;
|
|
101
|
+
}
|