@adecore/lsp 0.38.0 → 0.40.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/src/transport.ts CHANGED
@@ -17,9 +17,11 @@ class TransportEvents {
17
17
 
18
18
  onMessage(listener: (message: unknown) => void): Disposable {
19
19
  this.messages.add(listener);
20
+
20
21
  for (const message of this.backlog.splice(0)) {
21
22
  listener(message);
22
23
  }
24
+
23
25
  return {
24
26
  dispose: () => {
25
27
  this.messages.delete(listener);
@@ -29,9 +31,11 @@ class TransportEvents {
29
31
 
30
32
  onClose(listener: (error?: Error) => void): Disposable {
31
33
  this.closes.add(listener);
34
+
32
35
  if (this.closed) {
33
36
  listener(this.error);
34
37
  }
38
+
35
39
  return {
36
40
  dispose: () => {
37
41
  this.closes.delete(listener);
@@ -43,10 +47,13 @@ class TransportEvents {
43
47
  if (this.closed) {
44
48
  return;
45
49
  }
50
+
46
51
  if (this.messages.size === 0) {
47
52
  this.backlog.push(message);
53
+
48
54
  return;
49
55
  }
56
+
50
57
  for (const listener of this.messages) {
51
58
  listener(message);
52
59
  }
@@ -56,12 +63,15 @@ class TransportEvents {
56
63
  if (this.closed) {
57
64
  return;
58
65
  }
66
+
59
67
  this.closed = true;
60
68
  this.error = error;
61
69
  this.backlog = [];
70
+
62
71
  for (const listener of this.closes) {
63
72
  listener(error);
64
73
  }
74
+
65
75
  this.closes.clear();
66
76
  this.messages.clear();
67
77
  }
@@ -71,12 +81,15 @@ function concat(chunks: readonly Uint8Array[], size: number): Uint8Array {
71
81
  if (chunks.length === 1) {
72
82
  return chunks[0];
73
83
  }
84
+
74
85
  const joined = new Uint8Array(size);
75
86
  let offset = 0;
87
+
76
88
  for (const chunk of chunks) {
77
89
  joined.set(chunk, offset);
78
90
  offset += chunk.length;
79
91
  }
92
+
80
93
  return joined;
81
94
  }
82
95
 
@@ -89,12 +102,14 @@ function indexOfHeaderEnd(bytes: Uint8Array): number {
89
102
  return i;
90
103
  }
91
104
  }
105
+
92
106
  return -1;
93
107
  }
94
108
 
95
109
  /* Reads `Content-Length` framed messages, the way a language server speaks over stdio. */
96
110
  export class ContentLengthDecoder {
97
111
  private readonly maxMessageBytes: number;
112
+ private readonly utf8 = new TextDecoder();
98
113
  private chunks: Uint8Array[] = [];
99
114
  private size = 0;
100
115
  private length: number | null = null;
@@ -108,49 +123,64 @@ export class ContentLengthDecoder {
108
123
  this.chunks.push(chunk);
109
124
  this.size += chunk.length;
110
125
  const messages: unknown[] = [];
126
+
111
127
  while (true) {
112
128
  if (this.length === null) {
113
129
  const buffer = concat(this.chunks, this.size);
114
130
  this.chunks = [buffer];
115
131
  const boundary = indexOfHeaderEnd(buffer);
132
+
116
133
  if (boundary < 0) {
117
134
  if (buffer.length > MAX_HEADER_BYTES) {
118
135
  throw new Error('LSP header is too large');
119
136
  }
137
+
120
138
  break;
121
139
  }
122
- const header = new TextDecoder().decode(buffer.subarray(0, boundary));
140
+
141
+ const header = this.utf8.decode(buffer.subarray(0, boundary));
123
142
  const match = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
143
+
124
144
  if (!match || boundary > MAX_HEADER_BYTES) {
125
145
  throw new Error('Invalid LSP Content-Length header');
126
146
  }
147
+
127
148
  const length = Number(match[1]);
149
+
128
150
  if (!length || length > this.maxMessageBytes) {
129
151
  throw new Error('LSP message is too large');
130
152
  }
153
+
131
154
  this.length = length;
132
155
  this.chunks = [buffer.subarray(boundary + 4)];
133
156
  this.size = buffer.length - boundary - 4;
134
157
  }
158
+
135
159
  if (this.size < this.length) {
136
160
  break;
137
161
  }
162
+
138
163
  const buffer = concat(this.chunks, this.size);
139
- messages.push(JSON.parse(new TextDecoder().decode(buffer.subarray(0, this.length))));
164
+ messages.push(JSON.parse(this.utf8.decode(buffer.subarray(0, this.length))));
140
165
  this.chunks = [buffer.subarray(this.length)];
141
166
  this.size = buffer.length - this.length;
142
167
  this.length = null;
143
168
  }
169
+
144
170
  return messages;
145
171
  }
146
172
  }
147
173
 
174
+ let utf8: TextEncoder | undefined;
175
+
148
176
  export function encodeMessage(message: RpcMessage): Uint8Array {
149
- const body = new TextEncoder().encode(JSON.stringify(message));
150
- const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`);
177
+ utf8 ??= new TextEncoder();
178
+ const body = utf8.encode(JSON.stringify(message));
179
+ const header = utf8.encode(`Content-Length: ${body.length}\r\n\r\n`);
151
180
  const framed = new Uint8Array(header.length + body.length);
152
181
  framed.set(header, 0);
153
182
  framed.set(body, header.length);
183
+
154
184
  return framed;
155
185
  }
156
186
 
@@ -166,6 +196,7 @@ export interface ByteStream {
166
196
  export function createStreamTransport(stream: ByteStream, options: { maxMessageBytes?: number } = {}): LspTransport {
167
197
  const events = new TransportEvents();
168
198
  const decoder = new ContentLengthDecoder(options.maxMessageBytes);
199
+
169
200
  stream.onData((chunk) => {
170
201
  try {
171
202
  for (const message of decoder.accept(chunk)) {
@@ -176,7 +207,9 @@ export function createStreamTransport(stream: ByteStream, options: { maxMessageB
176
207
  void Promise.resolve(stream.close()).catch(() => undefined);
177
208
  }
178
209
  });
210
+
179
211
  stream.onClose((error) => events.finish(error));
212
+
180
213
  return {
181
214
  onMessage: (listener) => events.onMessage(listener),
182
215
  onClose: (listener) => events.onClose(listener),
@@ -184,6 +217,7 @@ export function createStreamTransport(stream: ByteStream, options: { maxMessageB
184
217
  if (events.closed) {
185
218
  throw new Error('LSP stream is closed');
186
219
  }
220
+
187
221
  await stream.write(encodeMessage(message));
188
222
  },
189
223
  async close() {
@@ -197,6 +231,7 @@ export function createStreamTransport(stream: ByteStream, options: { maxMessageB
197
231
  export function createMemoryTransportPair(): [LspTransport, LspTransport] {
198
232
  const left = new TransportEvents();
199
233
  const right = new TransportEvents();
234
+
200
235
  const end = (inbox: TransportEvents, outbox: TransportEvents): LspTransport => ({
201
236
  onMessage: (listener) => inbox.onMessage(listener),
202
237
  onClose: (listener) => inbox.onClose(listener),
@@ -204,6 +239,7 @@ export function createMemoryTransportPair(): [LspTransport, LspTransport] {
204
239
  if (inbox.closed || outbox.closed) {
205
240
  throw new Error('LSP transport is closed');
206
241
  }
242
+
207
243
  const copy: unknown = JSON.parse(JSON.stringify(message));
208
244
  queueMicrotask(() => outbox.receive(copy));
209
245
  },
@@ -212,6 +248,7 @@ export function createMemoryTransportPair(): [LspTransport, LspTransport] {
212
248
  outbox.finish();
213
249
  }
214
250
  });
251
+
215
252
  return [end(left, right), end(right, left)];
216
253
  }
217
254
 
@@ -232,24 +269,30 @@ export async function connectWebSocket(url: string, options: WebSocketTransportO
232
269
  if (options.signal?.aborted) {
233
270
  throw new Error('WebSocket connection cancelled');
234
271
  }
272
+
235
273
  const timeoutMs = options.timeoutMs ?? 10_000;
236
274
  const socket = options.createWebSocket ? options.createWebSocket(url, options.protocols) : new WebSocket(url, options.protocols);
237
275
  const events = new TransportEvents();
276
+
238
277
  socket.addEventListener('message', (event) => {
239
278
  try {
240
279
  if (typeof event.data !== 'string') {
241
280
  throw new Error('LSP WebSocket expects JSON text frames');
242
281
  }
282
+
243
283
  events.receive(JSON.parse(event.data));
244
284
  } catch (error) {
245
285
  events.finish(asError(error));
246
286
  socket.close(1003, 'Invalid JSON message');
247
287
  }
248
288
  });
289
+
249
290
  socket.addEventListener('close', (event) => events.finish(event.code === 1000 ? undefined : new Error(`LSP WebSocket closed (${event.code})`)));
250
291
  socket.addEventListener('error', () => events.finish(new Error('LSP WebSocket failed')));
292
+
251
293
  await new Promise<void>((resolve, reject) => {
252
294
  const timer = setTimeout(() => fail(new Error('LSP WebSocket connection timed out')), timeoutMs);
295
+
253
296
  const cleanup = (): void => {
254
297
  clearTimeout(timer);
255
298
  socket.removeEventListener('open', open);
@@ -257,37 +300,48 @@ export async function connectWebSocket(url: string, options: WebSocketTransportO
257
300
  socket.removeEventListener('close', closed);
258
301
  options.signal?.removeEventListener('abort', abort);
259
302
  };
303
+
260
304
  const fail = (reason: Error): void => {
261
305
  cleanup();
262
306
  socket.close();
263
307
  reject(reason);
264
308
  };
309
+
265
310
  const open = (): void => {
266
311
  cleanup();
267
312
  resolve();
268
313
  };
314
+
269
315
  const failed = (): void => fail(new Error('LSP WebSocket connection failed'));
316
+
270
317
  const closed = (): void => fail(new Error('LSP WebSocket closed before opening'));
318
+
271
319
  const abort = (): void => fail(new Error('LSP WebSocket connection cancelled'));
320
+
272
321
  socket.addEventListener('open', open, { once: true });
273
322
  socket.addEventListener('error', failed, { once: true });
274
323
  socket.addEventListener('close', closed, { once: true });
275
324
  options.signal?.addEventListener('abort', abort, { once: true });
276
325
  });
326
+
277
327
  return {
278
328
  onMessage: (listener) => events.onMessage(listener),
279
329
  onClose: (listener) => events.onClose(listener),
280
330
  async send(message) {
281
331
  const deadline = Date.now() + timeoutMs;
332
+
282
333
  while (!events.closed && socket.readyState === 1 && socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) {
283
334
  if (Date.now() >= deadline) {
284
335
  throw new Error('LSP WebSocket send buffer is full');
285
336
  }
337
+
286
338
  await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
287
339
  }
340
+
288
341
  if (events.closed || socket.readyState !== 1) {
289
342
  throw new Error('LSP WebSocket is closed');
290
343
  }
344
+
291
345
  socket.send(JSON.stringify(message));
292
346
  },
293
347
  close() {
package/src/uris.ts CHANGED
@@ -2,17 +2,25 @@ function isWindowsDrive(segment: string): boolean {
2
2
  return /^[A-Za-z]:$/.test(segment);
3
3
  }
4
4
 
5
+ /* 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. */
6
+ export function isWindowsPath(path: string): boolean {
7
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\');
8
+ }
9
+
5
10
  /* An absolute path, POSIX, with a Windows drive or UNC, as the `file:` URI a language server names it by. */
6
11
  export function pathToFileUri(path: string): string {
7
- // A backslash separates only in a Windows path; in a POSIX one it is part of a name.
8
- const windows = /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\');
12
+ const windows = isWindowsPath(path);
9
13
  const normalized = windows ? path.replaceAll('\\', '/') : path;
14
+
10
15
  if (windows && normalized.startsWith('//')) {
11
16
  const [host = '', ...segments] = normalized.slice(2).split('/');
17
+
12
18
  return `file://${encodeURIComponent(host)}/${segments.map(encodeURIComponent).join('/')}`;
13
19
  }
20
+
14
21
  const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`;
15
22
  const segments = rooted.split('/').map((segment, index) => (index === 1 && isWindowsDrive(segment) ? segment : encodeURIComponent(segment)));
23
+
16
24
  return `file://${segments.join('/')}`;
17
25
  }
18
26
 
@@ -20,22 +28,27 @@ export function pathToFileUri(path: string): string {
20
28
  export function fileUriToPath(uri: string): string | null {
21
29
  let url: URL;
22
30
  let path: string;
31
+
23
32
  try {
24
33
  url = new URL(uri);
25
34
  path = decodeURIComponent(url.pathname);
26
35
  } catch {
27
36
  return null;
28
37
  }
38
+
29
39
  if (url.protocol !== 'file:') {
30
40
  return null;
31
41
  }
42
+
32
43
  // A server in the URI names a UNC share, which only Windows reads as a path.
33
44
  if (url.hostname !== '' && url.hostname !== 'localhost') {
34
45
  return `\\\\${url.hostname}${path.replaceAll('/', '\\')}`;
35
46
  }
47
+
36
48
  if (/^\/[A-Za-z]:/.test(path)) {
37
49
  return path.slice(1).replaceAll('/', '\\');
38
50
  }
51
+
39
52
  return path;
40
53
  }
41
54
 
@@ -45,8 +58,10 @@ export function fileUriToPath(uri: string): string | null {
45
58
  */
46
59
  export function fileUriKey(uri: string): string {
47
60
  const path = fileUriToPath(uri);
61
+
48
62
  if (path === null || !/^[A-Za-z]:\\/.test(path)) {
49
63
  return uri;
50
64
  }
65
+
51
66
  return pathToFileUri(`${path[0]!.toUpperCase()}${path.slice(1)}`);
52
67
  }
package/src/vue.ts CHANGED
@@ -10,30 +10,37 @@ import type { LspSession } from './session.ts';
10
10
  */
11
11
  export function bridgeVueTypeScript(vue: LspSession, typescript: LspSession): Disposable {
12
12
  let disposed = false;
13
+
13
14
  const subscription = vue.onNotification('tsserver/request', async (params) => {
14
15
  // vscode-jsonrpc encodes this single positional argument as an outer array.
15
16
  const positional = Array.isArray(params) && params.length === 1 && Array.isArray(params[0]);
16
17
  const tuple = positional ? params[0] : params;
18
+
17
19
  if (!Array.isArray(tuple) || tuple.length !== 3) {
18
20
  throw new Error('Invalid Vue tsserver/request notification');
19
21
  }
22
+
20
23
  const [id, command, args] = tuple as [number, string, unknown];
21
24
  let body: unknown = null;
25
+
22
26
  try {
23
27
  const response = await typescript.executeCommand<{ body?: unknown }>('typescript.tsserverRequest', [
24
28
  command,
25
29
  args,
26
30
  { isAsync: false, lowPriority: true }
27
31
  ]);
32
+
28
33
  body = response?.body ?? null;
29
34
  } catch (error) {
30
35
  vue.connection.reportError(error);
31
36
  }
37
+
32
38
  // Vue keeps the request pending until it gets an answer, a null one included.
33
39
  if (!disposed && !vue.connection.isClosed) {
34
40
  await vue.connection.notify('tsserver/response', positional ? [[id, body]] : [id, body]);
35
41
  }
36
42
  });
43
+
37
44
  return {
38
45
  dispose() {
39
46
  disposed = true;
@@ -47,17 +54,21 @@ export type VueServer = 'vue' | 'typescript';
47
54
  /* A `<script>` body, or the expression of an interpolation or a `v-` / `:` / `@` attribute: the places TypeScript knows more than Vue does. */
48
55
  export function isVueExpression(text: string, offset: number): boolean {
49
56
  const script = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
57
+
50
58
  for (let match = script.exec(text); match; match = script.exec(text)) {
51
59
  if (offset > match.index + match[0].indexOf('>') && offset < match.index + match[0].lastIndexOf('</')) {
52
60
  return true;
53
61
  }
54
62
  }
63
+
55
64
  const expressions = /\{\{[\s\S]*?\}\}|(?:[:@][\w.:-]+|v-[\w.:-]+)\s*=\s*(?:"[^"]*"|'[^']*')/g;
65
+
56
66
  for (let match = expressions.exec(text); match; match = expressions.exec(text)) {
57
67
  if (offset >= match.index && offset <= match.index + match[0].length) {
58
68
  return true;
59
69
  }
60
70
  }
71
+
61
72
  return false;
62
73
  }
63
74
 
@@ -65,15 +76,18 @@ function paramsOffset(text: string, params: unknown): number | null {
65
76
  if (!params || typeof params !== 'object') {
66
77
  return null;
67
78
  }
79
+
68
80
  const position =
69
81
  'position' in params
70
82
  ? params.position
71
83
  : 'range' in params && params.range && typeof params.range === 'object' && 'start' in params.range
72
84
  ? params.range.start
73
85
  : null;
86
+
74
87
  if (!position || typeof position !== 'object') {
75
88
  return null;
76
89
  }
90
+
77
91
  try {
78
92
  return offsetAt(text, position as { line: number; character: number });
79
93
  } catch {
@@ -89,5 +103,6 @@ function paramsOffset(text: string, params: unknown): number | null {
89
103
  export function vueServerOrder(method: string, text: string, params: unknown): [VueServer, VueServer] {
90
104
  const offset = paramsOffset(text, params);
91
105
  const typescriptFirst = method === 'textDocument/inlayHint' || (offset !== null && isVueExpression(text, offset));
106
+
92
107
  return typescriptFirst ? ['typescript', 'vue'] : ['vue', 'typescript'];
93
108
  }
@@ -1,6 +1,6 @@
1
1
  import { globMatch } from './glob.ts';
2
2
  import type { FileSystemWatcher } from './protocol.ts';
3
- import { fileUriToPath } from './uris.ts';
3
+ import { fileUriToPath, isWindowsPath } from './uris.ts';
4
4
 
5
5
  /* A change of a file as the protocol numbers it: created, changed, deleted. */
6
6
  export type FileChangeType = 1 | 2 | 3;
@@ -12,25 +12,31 @@ const SPARING_FOLDERS = ['node_modules', 'vendor', '.git'];
12
12
 
13
13
  /* 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. */
14
14
  function slashed(path: string): string {
15
- if (!/^[A-Za-z]:[\\/]/.test(path) && !path.startsWith('\\\\')) {
15
+ if (!isWindowsPath(path)) {
16
16
  return path;
17
17
  }
18
+
18
19
  const forward = path.replaceAll('\\', '/');
20
+
19
21
  return /^[a-z]:/.test(forward) ? `${forward[0]!.toUpperCase()}${forward.slice(1)}` : forward;
20
22
  }
21
23
 
22
24
  function relativeTo(root: string, path: string): string | null {
23
25
  const prefix = root.endsWith('/') ? root : `${root}/`;
26
+
24
27
  return path.startsWith(prefix) ? path.slice(prefix.length) : null;
25
28
  }
26
29
 
27
30
  /* A pattern with the folder it is relative to, or null for a relative pattern whose base is no folder of this machine. */
28
31
  function patternOf(watcher: FileSystemWatcher): { base: string | undefined; pattern: string } | null {
29
32
  const { globPattern } = watcher;
33
+
30
34
  if (typeof globPattern === 'string') {
31
35
  return { base: undefined, pattern: globPattern };
32
36
  }
37
+
33
38
  const base = fileUriToPath(typeof globPattern.baseUri === 'string' ? globPattern.baseUri : globPattern.baseUri.uri);
39
+
34
40
  return base === null ? null : { base: slashed(base), pattern: globPattern.pattern };
35
41
  }
36
42
 
@@ -43,19 +49,25 @@ export function watchesFile(watchers: readonly FileSystemWatcher[], root: string
43
49
  const path = slashed(changed);
44
50
  const inProject = relativeTo(slashed(root), path);
45
51
  const segments = (inProject ?? path).split('/');
52
+
46
53
  return watchers.some((watcher) => {
47
54
  if (((watcher.kind ?? EVERY_CHANGE) & (1 << (type - 1))) === 0) {
48
55
  return false;
49
56
  }
57
+
50
58
  const parts = patternOf(watcher);
59
+
51
60
  if (parts === null) {
52
61
  return false;
53
62
  }
63
+
54
64
  const { base, pattern } = parts;
55
65
  const subject = pattern.startsWith('/') || /^[A-Za-z]:\//.test(pattern) ? path : base === undefined ? inProject : relativeTo(base, path);
66
+
56
67
  if (subject === null || !globMatch(subject, pattern)) {
57
68
  return false;
58
69
  }
70
+
59
71
  return SPARING_FOLDERS.every((folder) => !segments.includes(folder) || pattern.includes(folder));
60
72
  });
61
73
  }