@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/document.ts CHANGED
@@ -68,12 +68,15 @@ export class LspDocument {
68
68
  this.languageId = item.languageId;
69
69
  this.content = new TextMirror(item.text);
70
70
  this.revision = item.version ?? 1;
71
+
71
72
  if (!Number.isSafeInteger(this.revision) || this.revision < 0) {
72
73
  throw new LspError('Invalid document version');
73
74
  }
75
+
74
76
  this.ready = session.syncOptions.openClose
75
77
  ? session.notify('textDocument/didOpen', { textDocument: { ...item, version: this.revision } })
76
78
  : Promise.resolve();
79
+
77
80
  this.synced = this.ready;
78
81
  void this.ready.catch((error) => session.connection.reportError(error));
79
82
  }
@@ -94,9 +97,11 @@ export class LspDocument {
94
97
  updateText(text: string): Promise<void> {
95
98
  this.assertOpen();
96
99
  const current = this.content.text;
100
+
97
101
  if (text === current) {
98
102
  return this.synced;
99
103
  }
104
+
100
105
  return this.applyChanges([minimalChange(current, text)]);
101
106
  }
102
107
 
@@ -108,24 +113,32 @@ export class LspDocument {
108
113
  applyChanges(changes: readonly ContentChange[]): Promise<void> {
109
114
  this.assertOpen();
110
115
  const kind = this.session.syncOptions.change ?? 0;
116
+
111
117
  if (!kind) {
112
118
  return Promise.reject(new LspError('Server did not negotiate document changes', ErrorCodes.MethodNotFound));
113
119
  }
120
+
114
121
  try {
115
122
  this.content.applyChanges(changes);
116
123
  } catch (error) {
117
124
  return Promise.reject(error);
118
125
  }
126
+
119
127
  const version = ++this.revision;
120
128
  this.cancelRequests();
129
+
121
130
  if (kind === 1) {
122
131
  return this.queueFullText();
123
132
  }
133
+
124
134
  const contentChanges = changes.map((change) => ({ ...change }));
135
+
125
136
  this.synced = this.synced.then(() =>
126
137
  this.session.connection.notify('textDocument/didChange', { textDocument: { uri: this.uri, version }, contentChanges })
127
138
  );
139
+
128
140
  void this.synced.catch((error) => this.session.connection.reportError(error));
141
+
129
142
  return this.synced;
130
143
  }
131
144
 
@@ -138,6 +151,7 @@ export class LspDocument {
138
151
  await this.synced;
139
152
  this.assertCurrent(version);
140
153
  const save = this.session.syncOptions.save;
154
+
141
155
  if (save) {
142
156
  await this.session.notify('textDocument/didSave', {
143
157
  textDocument: { uri: this.uri },
@@ -282,12 +296,15 @@ export class LspDocument {
282
296
  if (this.closePromise) {
283
297
  return this.closePromise;
284
298
  }
299
+
285
300
  if (this.closed) {
286
301
  return Promise.resolve();
287
302
  }
303
+
288
304
  this.closed = true;
289
305
  this.cancelRequests();
290
306
  this.sendFullText?.();
307
+
291
308
  this.closePromise = this.synced
292
309
  .then(async () => {
293
310
  if (!this.session.connection.isClosed && this.session.syncOptions.openClose) {
@@ -295,6 +312,7 @@ export class LspDocument {
295
312
  }
296
313
  })
297
314
  .finally(() => this.session.forgetDocument(this));
315
+
298
316
  return this.closePromise;
299
317
  }
300
318
 
@@ -312,29 +330,39 @@ export class LspDocument {
312
330
 
313
331
  private resolve<T extends object>(method: string, item: T, options?: DocumentRequestOptions): Promise<T> {
314
332
  const version = this.resultVersions.get(item);
333
+
315
334
  if (version !== undefined && version !== this.version) {
316
335
  return Promise.reject(new StaleResultError(this.uri));
317
336
  }
337
+
318
338
  return this.perform(method, item, options);
319
339
  }
320
340
 
321
341
  private async perform<T>(method: string, params: unknown, options: DocumentRequestOptions = {}): Promise<T> {
322
342
  this.assertOpen();
343
+
323
344
  if (!this.session.supports(method, this)) {
324
345
  throw new LspError(`Server does not support ${method}`, ErrorCodes.MethodNotFound);
325
346
  }
347
+
326
348
  const version = this.version;
327
349
  const controller = new AbortController();
350
+
328
351
  if (options.cancelPrevious !== false) {
329
352
  this.latest.get(method)?.abort();
330
353
  }
354
+
331
355
  this.latest.set(method, controller);
332
356
  this.inFlight.add(controller);
357
+
333
358
  const abort = (): void => controller.abort();
359
+
334
360
  options.signal?.addEventListener('abort', abort, { once: true });
361
+
335
362
  if (options.signal?.aborted) {
336
363
  abort();
337
364
  }
365
+
338
366
  try {
339
367
  this.sendFullText?.();
340
368
  await this.synced;
@@ -342,13 +370,16 @@ export class LspDocument {
342
370
  const result = await this.session.request<T>(method, params, { signal: controller.signal, timeoutMs: options.timeoutMs });
343
371
  this.assertCurrent(version);
344
372
  this.remember(result, version);
373
+
345
374
  return result;
346
375
  } catch (error) {
347
376
  this.assertCurrent(version);
377
+
348
378
  throw error;
349
379
  } finally {
350
380
  options.signal?.removeEventListener('abort', abort);
351
381
  this.inFlight.delete(controller);
382
+
352
383
  if (this.latest.get(method) === controller) {
353
384
  this.latest.delete(method);
354
385
  }
@@ -360,7 +391,9 @@ export class LspDocument {
360
391
  if (!result || typeof result !== 'object') {
361
392
  return;
362
393
  }
394
+
363
395
  this.resultVersions.set(result, version);
396
+
364
397
  if (Array.isArray(result)) {
365
398
  for (const item of result) {
366
399
  this.remember(item, version);
@@ -387,17 +420,21 @@ export class LspDocument {
387
420
  if (this.sendFullText) {
388
421
  return this.synced;
389
422
  }
423
+
390
424
  const due = new Promise<void>((resolve) => {
391
425
  const timer = setTimeout(resolve, this.session.options.fullSyncDelayMs ?? FULL_SYNC_DELAY_MS);
426
+
392
427
  this.sendFullText = () => {
393
428
  clearTimeout(timer);
394
429
  resolve();
395
430
  };
396
431
  });
432
+
397
433
  this.synced = this.synced
398
434
  .then(() => due)
399
435
  .then(async () => {
400
436
  this.sendFullText = undefined;
437
+
401
438
  if (!this.session.connection.isClosed) {
402
439
  await this.session.connection.notify('textDocument/didChange', {
403
440
  textDocument: { uri: this.uri, version: this.revision },
@@ -405,7 +442,9 @@ export class LspDocument {
405
442
  });
406
443
  }
407
444
  });
445
+
408
446
  void this.synced.catch((error) => this.session.connection.reportError(error));
447
+
409
448
  return this.synced;
410
449
  }
411
450
 
package/src/edits.ts CHANGED
@@ -16,10 +16,10 @@ export interface PlannedDocumentEdit {
16
16
  created?: true;
17
17
  }
18
18
 
19
- const CR = 13;
20
- const LF = 10;
19
+ export const CR = 13;
20
+ export const LF = 10;
21
21
 
22
- function checkPosition(position: Position): void {
22
+ export function checkPosition(position: Position): void {
23
23
  if (!Number.isInteger(position.line) || !Number.isInteger(position.character) || position.line < 0 || position.character < 0) {
24
24
  throw new LspError('Invalid edit position');
25
25
  }
@@ -30,27 +30,35 @@ export function offsetAt(text: string, position: Position): number {
30
30
  checkPosition(position);
31
31
  let line = 0;
32
32
  let offset = 0;
33
+
33
34
  while (line < position.line && offset < text.length) {
34
35
  const char = text.charCodeAt(offset++);
36
+
35
37
  if (char === CR) {
36
38
  if (text.charCodeAt(offset) === LF) {
37
39
  offset++;
38
40
  }
41
+
39
42
  line++;
40
43
  } else if (char === LF) {
41
44
  line++;
42
45
  }
43
46
  }
47
+
44
48
  if (line !== position.line) {
45
49
  throw new LspError('Edit line is outside the document');
46
50
  }
51
+
47
52
  let end = offset;
53
+
48
54
  while (end < text.length && text[end] !== '\r' && text[end] !== '\n') {
49
55
  end++;
50
56
  }
57
+
51
58
  if (offset + position.character > end) {
52
59
  throw new LspError('Edit character is outside the line');
53
60
  }
61
+
54
62
  return offset + position.character;
55
63
  }
56
64
 
@@ -58,15 +66,19 @@ export function positionAt(text: string, offset: number): Position {
58
66
  if (!Number.isInteger(offset) || offset < 0 || offset > text.length) {
59
67
  throw new LspError('Offset is outside the document');
60
68
  }
69
+
61
70
  let line = 0;
62
71
  let lineStart = 0;
63
72
  let i = 0;
73
+
64
74
  while (i < offset) {
65
75
  const code = text.charCodeAt(i);
76
+
66
77
  if (code === CR && text.charCodeAt(i + 1) === LF) {
67
78
  if (i + 2 > offset) {
68
79
  break;
69
80
  }
81
+
70
82
  i += 2;
71
83
  line++;
72
84
  lineStart = i;
@@ -78,6 +90,7 @@ export function positionAt(text: string, offset: number): Position {
78
90
  i++;
79
91
  }
80
92
  }
93
+
81
94
  return { line, character: offset - lineStart };
82
95
  }
83
96
 
@@ -89,8 +102,10 @@ export function endPosition(text: string): Position {
89
102
  /* `offsetAt` for many positions in one text: the starts of its lines are found once instead of on every call. */
90
103
  function offsetsIn(text: string): (position: Position) => number {
91
104
  const starts = [0];
105
+
92
106
  for (let i = 0; i < text.length; i++) {
93
107
  const code = text.charCodeAt(i);
108
+
94
109
  if (code === CR && text.charCodeAt(i + 1) === LF) {
95
110
  i++;
96
111
  starts.push(i + 1);
@@ -98,17 +113,22 @@ function offsetsIn(text: string): (position: Position) => number {
98
113
  starts.push(i + 1);
99
114
  }
100
115
  }
116
+
101
117
  return (position) => {
102
118
  checkPosition(position);
103
119
  const start = starts[position.line];
120
+
104
121
  if (start === undefined) {
105
122
  throw new LspError('Edit line is outside the document');
106
123
  }
124
+
107
125
  const next = starts[position.line + 1];
108
126
  const end = next === undefined ? text.length : next - (text.charCodeAt(next - 1) === LF && text.charCodeAt(next - 2) === CR ? 2 : 1);
127
+
109
128
  if (start + position.character > end) {
110
129
  throw new LspError('Edit character is outside the line');
111
130
  }
131
+
112
132
  return start + position.character;
113
133
  };
114
134
  }
@@ -116,6 +136,7 @@ function offsetsIn(text: string): (position: Position) => number {
116
136
  /* Edits are simultaneous and in the coordinates of `text`; two inserts at one place keep their order. */
117
137
  export function applyTextEdits(text: string, edits: readonly TextEdit[]): string {
118
138
  const offsetOf = offsetsIn(text);
139
+
119
140
  const spans = edits
120
141
  .map((edit, index) => ({
121
142
  start: offsetOf(edit.range.start),
@@ -124,37 +145,49 @@ export function applyTextEdits(text: string, edits: readonly TextEdit[]): string
124
145
  index
125
146
  }))
126
147
  .sort((a, b) => a.start - b.start || a.index - b.index);
148
+
127
149
  let cursor = 0;
128
150
  const parts: string[] = [];
151
+
129
152
  for (const span of spans) {
130
153
  if (span.end < span.start) {
131
154
  throw new LspError('Edit range is reversed');
132
155
  }
156
+
133
157
  if (span.start < cursor) {
134
158
  throw new LspError('Workspace edits overlap');
135
159
  }
160
+
136
161
  parts.push(text.slice(cursor, span.start), span.text);
137
162
  cursor = span.end;
138
163
  }
164
+
139
165
  parts.push(text.slice(cursor));
166
+
140
167
  return parts.join('');
141
168
  }
142
169
 
143
170
  /* `didChange` entries apply one after the other, each in the coordinates the one before left. */
144
171
  export function applyContentChanges(text: string, changes: readonly ContentChange[]): string {
145
172
  let result = text;
173
+
146
174
  for (const change of changes) {
147
175
  if (!change.range) {
148
176
  result = change.text;
177
+
149
178
  continue;
150
179
  }
180
+
151
181
  const start = offsetAt(result, change.range.start);
152
182
  const end = offsetAt(result, change.range.end);
183
+
153
184
  if (end < start) {
154
185
  throw new LspError('Change range is reversed');
155
186
  }
187
+
156
188
  result = result.slice(0, start) + change.text + result.slice(end);
157
189
  }
190
+
158
191
  return result;
159
192
  }
160
193
 
@@ -174,22 +207,28 @@ function isLowSurrogate(code: number): boolean {
174
207
  export function minimalChange(before: string, after: string): ContentChange {
175
208
  const shortest = Math.min(before.length, after.length);
176
209
  let start = 0;
210
+
177
211
  while (start < shortest && before.charCodeAt(start) === after.charCodeAt(start)) {
178
212
  start++;
179
213
  }
214
+
180
215
  if (start > 0 && (isHighSurrogate(before.charCodeAt(start - 1)) || (before[start - 1] === '\r' && before[start] === '\n'))) {
181
216
  start--;
182
217
  }
218
+
183
219
  let end = 0;
220
+
184
221
  while (end < shortest - start && before.charCodeAt(before.length - 1 - end) === after.charCodeAt(after.length - 1 - end)) {
185
222
  end++;
186
223
  }
224
+
187
225
  if (
188
226
  end > 0 &&
189
227
  (isLowSurrogate(before.charCodeAt(before.length - end)) || (before[before.length - end] === '\n' && before[before.length - end - 1] === '\r'))
190
228
  ) {
191
229
  end--;
192
230
  }
231
+
193
232
  return {
194
233
  range: { start: positionAt(before, start), end: positionAt(before, before.length - end) },
195
234
  text: after.slice(start, after.length - end)
@@ -201,9 +240,11 @@ type KeyedSnapshots = ReadonlyMap<string, { uri: string; snapshot: DocumentSnaps
201
240
 
202
241
  function snapshotEdit(uri: string, snapshots: KeyedSnapshots): PlannedDocumentEdit {
203
242
  const found = snapshots.get(fileUriKey(uri));
243
+
204
244
  if (!found) {
205
245
  throw new LspError(`Missing workspace snapshot: ${uri}`);
206
246
  }
247
+
207
248
  return { uri: found.uri, version: found.snapshot.version, before: found.snapshot.text, text: found.snapshot.text };
208
249
  }
209
250
 
@@ -211,6 +252,7 @@ function snapshotEdit(uri: string, snapshots: KeyedSnapshots): PlannedDocumentEd
211
252
  function planCreate(change: CreateFile, snapshots: KeyedSnapshots, planned: Map<string, PlannedDocumentEdit>): void {
212
253
  const key = fileUriKey(change.uri);
213
254
  const existing = planned.get(key) ?? (snapshots.has(key) ? snapshotEdit(change.uri, snapshots) : null);
255
+
214
256
  if (existing === null) {
215
257
  planned.set(key, { uri: change.uri, version: null, before: '', text: '', created: true });
216
258
  } else if (change.options?.overwrite) {
@@ -220,6 +262,11 @@ function planCreate(change: CreateFile, snapshots: KeyedSnapshots, planned: Map<
220
262
  }
221
263
  }
222
264
 
265
+ /* The changes of an edit in order, with the older `changes` map read as unversioned text edits. */
266
+ export function documentChangesOf(edit: WorkspaceEdit): NonNullable<WorkspaceEdit['documentChanges']> {
267
+ return edit.documentChanges ?? Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ textDocument: { uri, version: null }, edits }));
268
+ }
269
+
223
270
  /*
224
271
  * Plans text edits and created files in memory, in the order the edit gives them. `snapshots` holds the
225
272
  * 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.
@@ -227,22 +274,28 @@ function planCreate(change: CreateFile, snapshots: KeyedSnapshots, planned: Map<
227
274
  export function planWorkspaceEdit(edit: WorkspaceEdit, snapshots: ReadonlyMap<string, DocumentSnapshot>): PlannedDocumentEdit[] {
228
275
  const keyed: KeyedSnapshots = new Map([...snapshots].map(([uri, snapshot]) => [fileUriKey(uri), { uri, snapshot }]));
229
276
  const planned = new Map<string, PlannedDocumentEdit>();
230
- const changes = edit.documentChanges ?? Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({ textDocument: { uri, version: null }, edits }));
231
- for (const change of changes) {
277
+
278
+ for (const change of documentChangesOf(edit)) {
232
279
  if ('kind' in change) {
233
280
  if (change.kind !== 'create') {
234
281
  throw new LspError(`A host file-operation handler is required for ${change.kind}`);
235
282
  }
283
+
236
284
  planCreate(change, keyed, planned);
285
+
237
286
  continue;
238
287
  }
288
+
239
289
  const { uri, version } = change.textDocument;
240
290
  const key = fileUriKey(uri);
241
291
  const current = planned.get(key) ?? snapshotEdit(uri, keyed);
292
+
242
293
  if (version !== null && current.version !== version) {
243
294
  throw new LspError(`Workspace edit version mismatch: ${uri}`, ErrorCodes.ContentModified);
244
295
  }
296
+
245
297
  planned.set(key, { ...current, text: applyTextEdits(current.text, change.edits) });
246
298
  }
299
+
247
300
  return [...planned.values()];
248
301
  }
@@ -18,33 +18,43 @@ export interface DeletedFile extends FileDelete {
18
18
 
19
19
  function filterTakes(filter: FileOperationFilter, uri: string, directory: boolean): boolean {
20
20
  const { matches, glob, options } = filter.pattern;
21
+
21
22
  if (matches !== undefined && (matches === 'folder') !== directory) {
22
23
  return false;
23
24
  }
25
+
24
26
  let url: URL;
27
+
25
28
  try {
26
29
  url = new URL(uri);
27
30
  } catch {
28
31
  return false;
29
32
  }
33
+
30
34
  if (filter.scheme !== undefined && filter.scheme !== url.protocol.slice(0, -1)) {
31
35
  return false;
32
36
  }
37
+
33
38
  const path = decodeURIComponent(url.pathname);
39
+
34
40
  return options?.ignoreCase === true ? globMatch(path.toLowerCase(), glob.toLowerCase()) : globMatch(path, glob);
35
41
  }
36
42
 
43
+ function anyTakes(filters: readonly FileOperationFilter[], uri: string, directory: boolean): boolean {
44
+ return filters.some((filter) => filterTakes(filter, uri, directory));
45
+ }
46
+
37
47
  /* The renames a server asked to hear about, by the filters it registered. A rename is matched on the place it leaves. */
38
48
  export function renamesTaken(filters: readonly FileOperationFilter[], files: readonly RenamedFile[]): FileRename[] {
39
- return files.filter((file) => filters.some((filter) => filterTakes(filter, file.oldUri, file.directory))).map(({ oldUri, newUri }) => ({ oldUri, newUri }));
49
+ return files.filter((file) => anyTakes(filters, file.oldUri, file.directory)).map(({ oldUri, newUri }) => ({ oldUri, newUri }));
40
50
  }
41
51
 
42
52
  /* The files made that a server asked to hear about, by the filters it registered. */
43
53
  export function createsTaken(filters: readonly FileOperationFilter[], files: readonly CreatedFile[]): FileCreate[] {
44
- return files.filter((file) => filters.some((filter) => filterTakes(filter, file.uri, file.directory))).map(({ uri }) => ({ uri }));
54
+ return files.filter((file) => anyTakes(filters, file.uri, file.directory)).map(({ uri }) => ({ uri }));
45
55
  }
46
56
 
47
57
  /* 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. */
48
58
  export function deletesTaken(filters: readonly FileOperationFilter[], files: readonly DeletedFile[]): FileDelete[] {
49
- return files.filter((file) => filters.some((filter) => filterTakes(filter, file.uri, file.directory))).map(({ uri }) => ({ uri }));
59
+ return files.filter((file) => anyTakes(filters, file.uri, file.directory)).map(({ uri }) => ({ uri }));
50
60
  }
package/src/glob.ts CHANGED
@@ -7,11 +7,14 @@ function escapeRegex(char: string): string {
7
7
  function compileGlob(pattern: string): RegExp | null {
8
8
  let expression = '';
9
9
  let depth = 0;
10
+
10
11
  for (let i = 0; i < pattern.length; i++) {
11
12
  const char = pattern[i];
13
+
12
14
  if (char === '*') {
13
15
  if (pattern[i + 1] === '*') {
14
16
  i++;
17
+
15
18
  if (pattern[i + 1] === '/') {
16
19
  expression += '(?:.*/)?';
17
20
  i++;
@@ -25,6 +28,7 @@ function compileGlob(pattern: string): RegExp | null {
25
28
  expression += '[^/]';
26
29
  } else if (char === '[') {
27
30
  const close = pattern.indexOf(']', i + 2);
31
+
28
32
  if (close === -1) {
29
33
  expression += '\\[';
30
34
  } else {
@@ -44,6 +48,7 @@ function compileGlob(pattern: string): RegExp | null {
44
48
  expression += escapeRegex(char);
45
49
  }
46
50
  }
51
+
47
52
  try {
48
53
  return new RegExp(`^${expression}$`);
49
54
  } catch {
@@ -56,5 +61,6 @@ export function globMatch(path: string, pattern: string): boolean {
56
61
  if (!compiled.has(pattern)) {
57
62
  compiled.set(pattern, compileGlob(pattern));
58
63
  }
64
+
59
65
  return compiled.get(pattern)?.test(path) ?? false;
60
66
  }