@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.
@@ -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
+ }