@adecore/lsp 0.39.0 → 0.40.1

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.
@@ -3,12 +3,10 @@ function validRange(range, singleLine = true) {
3
3
  return false;
4
4
  }
5
5
  const { start, end } = range;
6
- return (start !== undefined &&
6
+ return (typeof start === 'object' &&
7
7
  start !== null &&
8
- typeof start === 'object' &&
9
- end !== undefined &&
10
- end !== null &&
11
8
  typeof end === 'object' &&
9
+ end !== null &&
12
10
  [start.line, start.character, end.line, end.character].every((value) => Number.isSafeInteger(value) && value >= 0) &&
13
11
  (!singleLine || start.line === end.line) &&
14
12
  (start.line < end.line || (start.line === end.line && start.character <= end.character)));
package/dist/edits.d.ts CHANGED
@@ -10,10 +10,14 @@ export interface PlannedDocumentEdit {
10
10
  text: string;
11
11
  created?: true;
12
12
  }
13
+ export declare const CR = 13;
14
+ export declare const LF = 10;
15
+ export declare function checkPosition(position: Position): void;
13
16
  export declare function offsetAt(text: string, position: Position): number;
14
17
  export declare function positionAt(text: string, offset: number): Position;
15
18
  export declare function endPosition(text: string): Position;
16
19
  export declare function applyTextEdits(text: string, edits: readonly TextEdit[]): string;
17
20
  export declare function applyContentChanges(text: string, changes: readonly ContentChange[]): string;
18
21
  export declare function minimalChange(before: string, after: string): ContentChange;
22
+ export declare function documentChangesOf(edit: WorkspaceEdit): NonNullable<WorkspaceEdit['documentChanges']>;
19
23
  export declare function planWorkspaceEdit(edit: WorkspaceEdit, snapshots: ReadonlyMap<string, DocumentSnapshot>): PlannedDocumentEdit[];
package/dist/edits.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { ErrorCodes, LspError } from './connection.js';
2
2
  import { fileUriKey } from './uris.js';
3
- const CR = 13;
4
- const LF = 10;
5
- function checkPosition(position) {
3
+ export const CR = 13;
4
+ export const LF = 10;
5
+ export function checkPosition(position) {
6
6
  if (!Number.isInteger(position.line) || !Number.isInteger(position.character) || position.line < 0 || position.character < 0) {
7
7
  throw new LspError('Invalid edit position');
8
8
  }
@@ -192,6 +192,10 @@ function planCreate(change, snapshots, planned) {
192
192
  throw new LspError(`${change.uri} already exists`);
193
193
  }
194
194
  }
195
+ /* The changes of an edit in order, with the older `changes` map read as unversioned text edits. */
196
+ export function documentChangesOf(edit) {
197
+ return edit.documentChanges ?? Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ textDocument: { uri, version: null }, edits }));
198
+ }
195
199
  /*
196
200
  * Plans text edits and created files in memory, in the order the edit gives them. `snapshots` holds the
197
201
  * files that are there; a file the edit creates is absent from it. A rename or delete needs the host's own handling and is refused.
@@ -199,8 +203,7 @@ function planCreate(change, snapshots, planned) {
199
203
  export function planWorkspaceEdit(edit, snapshots) {
200
204
  const keyed = new Map([...snapshots].map(([uri, snapshot]) => [fileUriKey(uri), { uri, snapshot }]));
201
205
  const planned = new Map();
202
- const changes = edit.documentChanges ?? Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ textDocument: { uri, version: null }, edits }));
203
- for (const change of changes) {
206
+ for (const change of documentChangesOf(edit)) {
204
207
  if ('kind' in change) {
205
208
  if (change.kind !== 'create') {
206
209
  throw new LspError(`A host file-operation handler is required for ${change.kind}`);
@@ -17,15 +17,18 @@ function filterTakes(filter, uri, directory) {
17
17
  const path = decodeURIComponent(url.pathname);
18
18
  return options?.ignoreCase === true ? globMatch(path.toLowerCase(), glob.toLowerCase()) : globMatch(path, glob);
19
19
  }
20
+ function anyTakes(filters, uri, directory) {
21
+ return filters.some((filter) => filterTakes(filter, uri, directory));
22
+ }
20
23
  /* The renames a server asked to hear about, by the filters it registered. A rename is matched on the place it leaves. */
21
24
  export function renamesTaken(filters, files) {
22
- return files.filter((file) => filters.some((filter) => filterTakes(filter, file.oldUri, file.directory))).map(({ oldUri, newUri }) => ({ oldUri, newUri }));
25
+ return files.filter((file) => anyTakes(filters, file.oldUri, file.directory)).map(({ oldUri, newUri }) => ({ oldUri, newUri }));
23
26
  }
24
27
  /* The files made that a server asked to hear about, by the filters it registered. */
25
28
  export function createsTaken(filters, files) {
26
- return files.filter((file) => filters.some((filter) => filterTakes(filter, file.uri, file.directory))).map(({ uri }) => ({ uri }));
29
+ return files.filter((file) => anyTakes(filters, file.uri, file.directory)).map(({ uri }) => ({ uri }));
27
30
  }
28
31
  /* The files deleted that a server asked to hear about, by the filters it registered. A folder is matched as one, not by what was in it. */
29
32
  export function deletesTaken(filters, files) {
30
- return files.filter((file) => filters.some((filter) => filterTakes(filter, file.uri, file.directory))).map(({ uri }) => ({ uri }));
33
+ return files.filter((file) => anyTakes(filters, file.uri, file.directory)).map(({ uri }) => ({ uri }));
31
34
  }
package/dist/session.d.ts CHANGED
@@ -80,6 +80,8 @@ export declare class LspSession {
80
80
  shutdown(): Promise<void>;
81
81
  forgetDocument(document: LspDocument): void;
82
82
  private assertReady;
83
+ private askBefore;
84
+ private tellAfter;
83
85
  private stop;
84
86
  private readConfiguration;
85
87
  private emitDiagnostics;
package/dist/session.js CHANGED
@@ -307,39 +307,27 @@ export class LspSession {
307
307
  }
308
308
  /* What the server would change before these files move, or null when it takes none of them or has nothing to say. */
309
309
  async willRenameFiles(files, options) {
310
- const taken = renamesTaken(this.fileOperationFilters('willRename'), files);
311
- return taken.length === 0 || this.phase !== 'ready' ? null : this.request('workspace/willRenameFiles', { files: taken }, options);
310
+ return this.askBefore('willRename', renamesTaken(this.fileOperationFilters('willRename'), files), options);
312
311
  }
313
312
  /* Tells the server which of these files moved, those its filters take. */
314
313
  async didRenameFiles(files) {
315
- const taken = renamesTaken(this.fileOperationFilters('didRename'), files);
316
- if (taken.length > 0 && this.phase === 'ready') {
317
- await this.notify('workspace/didRenameFiles', { files: taken });
318
- }
314
+ await this.tellAfter('didRename', renamesTaken(this.fileOperationFilters('didRename'), files));
319
315
  }
320
316
  /* What the server would change before these files are made, or null when it takes none of them or has nothing to say. */
321
317
  async willCreateFiles(files, options) {
322
- const taken = createsTaken(this.fileOperationFilters('willCreate'), files);
323
- return taken.length === 0 || this.phase !== 'ready' ? null : this.request('workspace/willCreateFiles', { files: taken }, options);
318
+ return this.askBefore('willCreate', createsTaken(this.fileOperationFilters('willCreate'), files), options);
324
319
  }
325
320
  /* Tells the server which of these files were made, those its filters take. */
326
321
  async didCreateFiles(files) {
327
- const taken = createsTaken(this.fileOperationFilters('didCreate'), files);
328
- if (taken.length > 0 && this.phase === 'ready') {
329
- await this.notify('workspace/didCreateFiles', { files: taken });
330
- }
322
+ await this.tellAfter('didCreate', createsTaken(this.fileOperationFilters('didCreate'), files));
331
323
  }
332
324
  /* What the server would change before these files are deleted, or null when it takes none of them or has nothing to say. */
333
325
  async willDeleteFiles(files, options) {
334
- const taken = deletesTaken(this.fileOperationFilters('willDelete'), files);
335
- return taken.length === 0 || this.phase !== 'ready' ? null : this.request('workspace/willDeleteFiles', { files: taken }, options);
326
+ return this.askBefore('willDelete', deletesTaken(this.fileOperationFilters('willDelete'), files), options);
336
327
  }
337
328
  /* Tells the server which of these files were deleted, those its filters take. */
338
329
  async didDeleteFiles(files) {
339
- const taken = deletesTaken(this.fileOperationFilters('didDelete'), files);
340
- if (taken.length > 0 && this.phase === 'ready') {
341
- await this.notify('workspace/didDeleteFiles', { files: taken });
342
- }
330
+ await this.tellAfter('didDelete', deletesTaken(this.fileOperationFilters('didDelete'), files));
343
331
  }
344
332
  /* Registrations and the refresh requests of the server both land here, since each can change what a document may ask. */
345
333
  onCapabilitiesChanged(listener) {
@@ -367,6 +355,14 @@ export class LspSession {
367
355
  throw new LspError(`LSP session is ${this.phase}; await initialize() first`, ErrorCodes.ServerNotInitialized);
368
356
  }
369
357
  }
358
+ async askBefore(operation, files, options) {
359
+ return files.length === 0 || this.phase !== 'ready' ? null : this.request(`workspace/${operation}Files`, { files }, options);
360
+ }
361
+ async tellAfter(operation, files) {
362
+ if (files.length > 0 && this.phase === 'ready') {
363
+ await this.notify(`workspace/${operation}Files`, { files });
364
+ }
365
+ }
370
366
  async stop() {
371
367
  try {
372
368
  if (this.phase === 'initializing') {
@@ -42,7 +42,7 @@ export class ScriptedTransport {
42
42
  }
43
43
  /* The last request of `method` the client sent. */
44
44
  request(method) {
45
- return [...this.sent].reverse().find((message) => 'method' in message && message.method === method && 'id' in message);
45
+ return this.sent.findLast((message) => 'method' in message && message.method === method && 'id' in message);
46
46
  }
47
47
  respond(method, result) {
48
48
  this.emit({ jsonrpc: '2.0', id: this.request(method).id, result });
@@ -1,6 +1,5 @@
1
1
  import { LspError } from './connection.js';
2
- const CR = 13;
3
- const LF = 10;
2
+ import { CR, LF, checkPosition } from './edits.js';
4
3
  /* Past this many lines a spread into `splice` could exceed the engine's argument limit. */
5
4
  const SPREAD_LIMIT = 10_000;
6
5
  /* Each line keeps its own break, so the last one is the only line without. */
@@ -92,9 +91,7 @@ export class TextMirror {
92
91
  return this.replace(first, end.line - first + 1, inserted);
93
92
  }
94
93
  lineAt(position) {
95
- if (!Number.isInteger(position.line) || !Number.isInteger(position.character) || position.line < 0 || position.character < 0) {
96
- throw new LspError('Invalid edit position');
97
- }
94
+ checkPosition(position);
98
95
  const line = this.lines[position.line];
99
96
  if (line === undefined) {
100
97
  throw new LspError('Edit line is outside the document');
@@ -1,6 +1,7 @@
1
1
  import type { Disposable, LspTransport, RpcMessage } from './protocol.ts';
2
2
  export declare class ContentLengthDecoder {
3
3
  private readonly maxMessageBytes;
4
+ private readonly utf8;
4
5
  private chunks;
5
6
  private size;
6
7
  private length;
package/dist/transport.js CHANGED
@@ -84,6 +84,7 @@ function indexOfHeaderEnd(bytes) {
84
84
  /* Reads `Content-Length` framed messages, the way a language server speaks over stdio. */
85
85
  export class ContentLengthDecoder {
86
86
  maxMessageBytes;
87
+ utf8 = new TextDecoder();
87
88
  chunks = [];
88
89
  size = 0;
89
90
  length = null;
@@ -106,7 +107,7 @@ export class ContentLengthDecoder {
106
107
  }
107
108
  break;
108
109
  }
109
- const header = new TextDecoder().decode(buffer.subarray(0, boundary));
110
+ const header = this.utf8.decode(buffer.subarray(0, boundary));
110
111
  const match = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
111
112
  if (!match || boundary > MAX_HEADER_BYTES) {
112
113
  throw new Error('Invalid LSP Content-Length header');
@@ -123,7 +124,7 @@ export class ContentLengthDecoder {
123
124
  break;
124
125
  }
125
126
  const buffer = concat(this.chunks, this.size);
126
- messages.push(JSON.parse(new TextDecoder().decode(buffer.subarray(0, this.length))));
127
+ messages.push(JSON.parse(this.utf8.decode(buffer.subarray(0, this.length))));
127
128
  this.chunks = [buffer.subarray(this.length)];
128
129
  this.size = buffer.length - this.length;
129
130
  this.length = null;
@@ -131,9 +132,11 @@ export class ContentLengthDecoder {
131
132
  return messages;
132
133
  }
133
134
  }
135
+ let utf8;
134
136
  export function encodeMessage(message) {
135
- const body = new TextEncoder().encode(JSON.stringify(message));
136
- const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`);
137
+ utf8 ??= new TextEncoder();
138
+ const body = utf8.encode(JSON.stringify(message));
139
+ const header = utf8.encode(`Content-Length: ${body.length}\r\n\r\n`);
137
140
  const framed = new Uint8Array(header.length + body.length);
138
141
  framed.set(header, 0);
139
142
  framed.set(body, header.length);
package/dist/uris.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare function isWindowsPath(path: string): boolean;
1
2
  export declare function pathToFileUri(path: string): string;
2
3
  export declare function fileUriToPath(uri: string): string | null;
3
4
  export declare function fileUriKey(uri: string): string;
package/dist/uris.js CHANGED
@@ -1,10 +1,13 @@
1
1
  function isWindowsDrive(segment) {
2
2
  return /^[A-Za-z]:$/.test(segment);
3
3
  }
4
+ /* A path with a drive or a UNC share. A backslash separates only in such a path; in a POSIX one it is part of a name. */
5
+ export function isWindowsPath(path) {
6
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\');
7
+ }
4
8
  /* An absolute path, POSIX, with a Windows drive or UNC, as the `file:` URI a language server names it by. */
5
9
  export function pathToFileUri(path) {
6
- // A backslash separates only in a Windows path; in a POSIX one it is part of a name.
7
- const windows = /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\');
10
+ const windows = isWindowsPath(path);
8
11
  const normalized = windows ? path.replaceAll('\\', '/') : path;
9
12
  if (windows && normalized.startsWith('//')) {
10
13
  const [host = '', ...segments] = normalized.slice(2).split('/');
@@ -1,11 +1,11 @@
1
1
  import { globMatch } from './glob.js';
2
- import { fileUriToPath } from './uris.js';
2
+ import { fileUriToPath, isWindowsPath } from './uris.js';
3
3
  const EVERY_CHANGE = 7;
4
4
  /* Folders whose files a server hears about only when a pattern of its own names the folder. */
5
5
  const SPARING_FOLDERS = ['node_modules', 'vendor', '.git'];
6
6
  /* A Windows path with forward slashes and its drive in capitals, so it compares with a pattern and a root however it was written. A POSIX path is left alone, a backslash being part of a name there. */
7
7
  function slashed(path) {
8
- if (!/^[A-Za-z]:[\\/]/.test(path) && !path.startsWith('\\\\')) {
8
+ if (!isWindowsPath(path)) {
9
9
  return path;
10
10
  }
11
11
  const forward = path.replaceAll('\\', '/');
@@ -1,5 +1,5 @@
1
1
  import { ErrorCodes, LspError } from './connection.js';
2
- import { applyTextEdits } from './edits.js';
2
+ import { applyTextEdits, documentChangesOf } from './edits.js';
3
3
  /* The key a URI is known by: a folder spelled with a trailing slash is the same folder, except the root. */
4
4
  function keyOf(uri) {
5
5
  const root = /^[^/:]+:\/\/[^/]*\/+$/.test(uri);
@@ -122,8 +122,7 @@ export async function planWorkspaceOperations(edit, source) {
122
122
  }
123
123
  return { ...change, uri: keyOf(change.uri) };
124
124
  };
125
- const changes = edit.documentChanges ?? Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ textDocument: { uri, version: null }, edits }));
126
- for (const original of changes) {
125
+ for (const original of documentChangesOf(edit)) {
127
126
  const change = keyed(original);
128
127
  if ('textDocument' in change) {
129
128
  const { uri, version } = change.textDocument;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/lsp",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -20,7 +20,7 @@
20
20
  "test": "bun test --conditions=source"
21
21
  },
22
22
  "devDependencies": {
23
- "@types/bun": "^1.4.2",
23
+ "@types/bun": "^1.4.4",
24
24
  "typescript": "^7.0.2"
25
25
  },
26
26
  "license": "FSL-1.1-MIT",
@@ -4,14 +4,14 @@ function validRange(range: Range, singleLine = true): boolean {
4
4
  if (range === null || typeof range !== 'object') {
5
5
  return false;
6
6
  }
7
+
7
8
  const { start, end } = range;
9
+
8
10
  return (
9
- start !== undefined &&
10
- start !== null &&
11
11
  typeof start === 'object' &&
12
- end !== undefined &&
13
- end !== null &&
12
+ start !== null &&
14
13
  typeof end === 'object' &&
14
+ end !== null &&
15
15
  [start.line, start.character, end.line, end.character].every((value) => Number.isSafeInteger(value) && value >= 0) &&
16
16
  (!singleLine || start.line === end.line) &&
17
17
  (start.line < end.line || (start.line === end.line && start.character <= end.character))
@@ -22,9 +22,11 @@ function validEditRange(range: Range | { insert: Range; replace: Range }, single
22
22
  if (range === null || typeof range !== 'object' || Array.isArray(range)) {
23
23
  return false;
24
24
  }
25
+
25
26
  if ('start' in range) {
26
27
  return validRange(range, singleLine);
27
28
  }
29
+
28
30
  return (
29
31
  validRange(range.insert) &&
30
32
  validRange(range.replace) &&
@@ -42,13 +44,16 @@ export function applyCompletionDefaults(
42
44
  if (typeof item?.label !== 'string') {
43
45
  throw new TypeError('A completion item must have a string label.');
44
46
  }
47
+
45
48
  for (const value of [item.filterText, item.sortText, item.insertText, item.textEditText]) {
46
49
  if (value !== undefined && typeof value !== 'string') {
47
50
  throw new TypeError('Completion ranking and insertion text must be strings.');
48
51
  }
49
52
  }
53
+
50
54
  if (item.textEdit !== undefined) {
51
55
  const edit = item.textEdit;
56
+
52
57
  if (
53
58
  edit === null ||
54
59
  typeof edit !== 'object' ||
@@ -58,40 +63,55 @@ export function applyCompletionDefaults(
58
63
  throw new RangeError('Invalid completion item edit.');
59
64
  }
60
65
  }
66
+
61
67
  if (defaults === undefined) {
62
68
  return item;
63
69
  }
70
+
64
71
  const next = { ...item };
72
+
65
73
  if (next.commitCharacters === undefined && defaults.commitCharacters !== undefined) {
66
74
  next.commitCharacters = defaults.commitCharacters;
67
75
  }
76
+
68
77
  if (next.insertTextFormat === undefined && defaults.insertTextFormat !== undefined) {
69
78
  next.insertTextFormat = defaults.insertTextFormat;
70
79
  }
80
+
71
81
  if (next.insertTextMode === undefined && defaults.insertTextMode !== undefined) {
72
82
  next.insertTextMode = defaults.insertTextMode;
73
83
  }
84
+
74
85
  if (next.data === undefined && defaults.data !== undefined) {
75
86
  next.data = defaults.data;
76
87
  }
88
+
77
89
  if (next.textEdit === undefined && defaults.editRange !== undefined) {
78
90
  const range = defaults.editRange;
79
91
  const newText = item.textEditText ?? item.label;
92
+
80
93
  if (typeof newText !== 'string') {
81
94
  throw new TypeError('Completion edit text must be a string.');
82
95
  }
96
+
83
97
  const invalid = (message: string): CompletionItem => {
84
98
  const error = new RangeError(message);
99
+
85
100
  if (options?.onInvalidRange === undefined) {
86
101
  throw error;
87
102
  }
103
+
88
104
  options.onInvalidRange(error);
105
+
89
106
  return next;
90
107
  };
108
+
91
109
  if (!validEditRange(range)) {
92
110
  return invalid('Invalid completion default edit range.');
93
111
  }
112
+
94
113
  next.textEdit = 'start' in range ? { range, newText } : { insert: range.insert, replace: range.replace, newText };
95
114
  }
115
+
96
116
  return next;
97
117
  }
package/src/connection.ts CHANGED
@@ -66,6 +66,7 @@ export class JsonRpcConnection {
66
66
  constructor(transport: LspTransport, timeoutMs = 30_000) {
67
67
  this.transport = transport;
68
68
  this.timeoutMs = timeoutMs;
69
+
69
70
  this.subscriptions = [
70
71
  transport.onMessage((message) => {
71
72
  void this.receive(message).catch((error) => this.reportError(error));
@@ -82,20 +83,27 @@ export class JsonRpcConnection {
82
83
  if (this.closed) {
83
84
  return Promise.reject(new LspError('LSP connection is closed'));
84
85
  }
86
+
85
87
  if (options.signal?.aborted) {
86
88
  return Promise.reject(cancelledError());
87
89
  }
90
+
88
91
  const id = ++this.nextId;
92
+
89
93
  return new Promise<T>((resolve, reject) => {
90
94
  let timer: ReturnType<typeof setTimeout> | undefined;
95
+
91
96
  const cancel = (error: LspError): void => {
92
97
  if (!this.pending.has(id)) {
93
98
  return;
94
99
  }
100
+
95
101
  this.settle(id, undefined, error);
96
102
  void this.notify('$/cancelRequest', { id }).catch((failure) => this.reportError(failure));
97
103
  };
104
+
98
105
  const abort = (): void => cancel(cancelledError());
106
+
99
107
  this.pending.set(id, {
100
108
  resolve: (value) => resolve(value as T),
101
109
  reject,
@@ -104,11 +112,14 @@ export class JsonRpcConnection {
104
112
  options.signal?.removeEventListener('abort', abort);
105
113
  }
106
114
  });
115
+
107
116
  options.signal?.addEventListener('abort', abort, { once: true });
108
117
  const timeout = options.timeoutMs ?? this.timeoutMs;
118
+
109
119
  if (timeout > 0) {
110
120
  timer = setTimeout(() => cancel(new LspError(`LSP request timed out: ${method}`, ErrorCodes.RequestCancelled)), timeout);
111
121
  }
122
+
112
123
  void this.send({ jsonrpc: '2.0', id, method, params }).catch((error) => this.settle(id, undefined, error));
113
124
  });
114
125
  }
@@ -121,7 +132,9 @@ export class JsonRpcConnection {
121
132
  if (this.handlers.has(method)) {
122
133
  throw new Error(`LSP request handler already registered: ${method}`);
123
134
  }
135
+
124
136
  this.handlers.set(method, handler);
137
+
125
138
  return {
126
139
  dispose: () => {
127
140
  if (this.handlers.get(method) === handler) {
@@ -135,9 +148,11 @@ export class JsonRpcConnection {
135
148
  const listeners = this.notifications.get(method) ?? new Set<NotificationHandler>();
136
149
  this.notifications.set(method, listeners);
137
150
  listeners.add(listener);
151
+
138
152
  return {
139
153
  dispose: () => {
140
154
  listeners.delete(listener);
155
+
141
156
  if (listeners.size === 0) {
142
157
  this.notifications.delete(method);
143
158
  }
@@ -147,6 +162,7 @@ export class JsonRpcConnection {
147
162
 
148
163
  onError(listener: (error: Error) => void): Disposable {
149
164
  this.errors.add(listener);
165
+
150
166
  return {
151
167
  dispose: () => {
152
168
  this.errors.delete(listener);
@@ -156,9 +172,11 @@ export class JsonRpcConnection {
156
172
 
157
173
  onClose(listener: (error?: Error) => void): Disposable {
158
174
  this.closes.add(listener);
175
+
159
176
  if (this.closed) {
160
177
  listener();
161
178
  }
179
+
162
180
  return {
163
181
  dispose: () => {
164
182
  this.closes.delete(listener);
@@ -168,6 +186,7 @@ export class JsonRpcConnection {
168
186
 
169
187
  reportError(error: unknown): void {
170
188
  const normalized = asError(error);
189
+
171
190
  for (const listener of this.errors) {
172
191
  try {
173
192
  listener(normalized);
@@ -187,19 +206,25 @@ export class JsonRpcConnection {
187
206
  if (this.closed) {
188
207
  throw new LspError('LSP connection is closed');
189
208
  }
209
+
190
210
  await this.transport.send(message);
191
211
  });
212
+
192
213
  this.sendQueue = sent.catch((error) => this.finish(asError(error)));
214
+
193
215
  return sent;
194
216
  }
195
217
 
196
218
  private settle(id: RequestId, result?: unknown, error?: unknown): void {
197
219
  const request = this.pending.get(id);
220
+
198
221
  if (!request) {
199
222
  return;
200
223
  }
224
+
201
225
  this.pending.delete(id);
202
226
  request.cleanup();
227
+
203
228
  if (error !== undefined) {
204
229
  request.reject(error);
205
230
  } else {
@@ -211,21 +236,27 @@ export class JsonRpcConnection {
211
236
  if (this.closed) {
212
237
  return;
213
238
  }
239
+
214
240
  if (!value || typeof value !== 'object' || !('jsonrpc' in value) || value.jsonrpc !== '2.0') {
215
241
  throw new LspError('Invalid JSON-RPC message', ErrorCodes.InvalidRequest);
216
242
  }
243
+
217
244
  const message = value as RpcMessage;
245
+
218
246
  if ('method' in message) {
219
247
  if (typeof message.method !== 'string') {
220
248
  throw new LspError('Invalid JSON-RPC method', ErrorCodes.InvalidRequest);
221
249
  }
250
+
222
251
  if ('id' in message) {
223
252
  if (typeof message.id !== 'number' && typeof message.id !== 'string') {
224
253
  throw new LspError('Invalid request id', ErrorCodes.InvalidRequest);
225
254
  }
255
+
226
256
  await this.handleRequest(message);
227
257
  } else if (message.method === '$/cancelRequest') {
228
258
  const id = (message.params as { id?: RequestId } | undefined)?.id;
259
+
229
260
  if (id !== undefined) {
230
261
  this.incoming.get(id)?.abort();
231
262
  }
@@ -254,18 +285,24 @@ export class JsonRpcConnection {
254
285
  private async handleRequest(message: RpcRequest): Promise<void> {
255
286
  const controller = new AbortController();
256
287
  this.incoming.set(message.id, controller);
288
+
257
289
  try {
258
290
  const handler = this.handlers.get(message.method);
291
+
259
292
  if (!handler) {
260
293
  throw new LspError(`Unsupported server request: ${message.method}`, ErrorCodes.MethodNotFound);
261
294
  }
295
+
262
296
  const result = await handler(message.params, controller.signal);
297
+
263
298
  if (controller.signal.aborted) {
264
299
  throw cancelledError();
265
300
  }
301
+
266
302
  await this.send({ jsonrpc: '2.0', id: message.id, result: result ?? null });
267
303
  } catch (error) {
268
304
  const failure = error instanceof LspError ? error : new LspError(asError(error).message);
305
+
269
306
  if (!this.closed) {
270
307
  await this.send({ jsonrpc: '2.0', id: message.id, error: { code: failure.code, message: failure.message, data: failure.data } });
271
308
  }
@@ -278,18 +315,24 @@ export class JsonRpcConnection {
278
315
  if (this.closed) {
279
316
  return;
280
317
  }
318
+
281
319
  this.closed = true;
320
+
282
321
  for (const id of [...this.pending.keys()]) {
283
322
  this.settle(id, undefined, error ?? new LspError('LSP connection closed'));
284
323
  }
324
+
285
325
  for (const controller of this.incoming.values()) {
286
326
  controller.abort();
287
327
  }
328
+
288
329
  this.incoming.clear();
330
+
289
331
  // A transport that is closed already calls back from inside the constructor, before the subscriptions exist.
290
332
  for (const subscription of this.subscriptions ?? []) {
291
333
  subscription.dispose();
292
334
  }
335
+
293
336
  for (const listener of this.closes) {
294
337
  try {
295
338
  listener(error);
@@ -297,9 +340,11 @@ export class JsonRpcConnection {
297
340
  this.reportError(failure);
298
341
  }
299
342
  }
343
+
300
344
  this.handlers.clear();
301
345
  this.notifications.clear();
302
346
  this.closes.clear();
347
+
303
348
  if (error) {
304
349
  this.reportError(error);
305
350
  }