@adecore/lsp 0.23.1 → 0.24.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.
@@ -6,9 +6,10 @@ export declare class LspDocument {
6
6
  readonly languageId: string;
7
7
  readonly ready: Promise<void>;
8
8
  private revision;
9
- private content;
9
+ private readonly content;
10
10
  private closed;
11
11
  private synced;
12
+ private sendFullText?;
12
13
  private closePromise?;
13
14
  private readonly inFlight;
14
15
  private readonly latest;
@@ -60,5 +61,6 @@ export declare class LspDocument {
60
61
  private remember;
61
62
  private assertOpen;
62
63
  private assertCurrent;
64
+ private queueFullText;
63
65
  private cancelRequests;
64
66
  }
package/dist/document.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { ErrorCodes, LspError, StaleResultError } from './connection.js';
2
- import { applyContentChanges, minimalChange } from './edits.js';
2
+ import { minimalChange } from './edits.js';
3
+ import { TextMirror } from './text-mirror.js';
4
+ /* The delay of the VS Code language client for servers with full sync. */
5
+ const FULL_SYNC_DELAY_MS = 200;
3
6
  /*
4
7
  * One open text document in one session. It owns the version: every change raises it by one, and a
5
8
  * result that arrives for an older version is refused, so nothing computed for a text that is gone
@@ -14,6 +17,7 @@ export class LspDocument {
14
17
  content;
15
18
  closed = false;
16
19
  synced;
20
+ sendFullText;
17
21
  closePromise;
18
22
  inFlight = new Set();
19
23
  latest = new Map();
@@ -24,7 +28,7 @@ export class LspDocument {
24
28
  this.session = session;
25
29
  this.uri = item.uri;
26
30
  this.languageId = item.languageId;
27
- this.content = item.text;
31
+ this.content = new TextMirror(item.text);
28
32
  this.revision = item.version ?? 1;
29
33
  if (!Number.isSafeInteger(this.revision) || this.revision < 0) {
30
34
  throw new LspError('Invalid document version');
@@ -39,7 +43,7 @@ export class LspDocument {
39
43
  return this.revision;
40
44
  }
41
45
  get text() {
42
- return this.content;
46
+ return this.content.text;
43
47
  }
44
48
  get isClosed() {
45
49
  return this.closed;
@@ -47,14 +51,16 @@ export class LspDocument {
47
51
  /* Sends the difference to the text as the one range that changed, or the whole text to a server that wants no ranges. */
48
52
  updateText(text) {
49
53
  this.assertOpen();
50
- if (text === this.content) {
54
+ const current = this.content.text;
55
+ if (text === current) {
51
56
  return this.synced;
52
57
  }
53
- return this.applyChanges([minimalChange(this.content, text)]);
58
+ return this.applyChanges([minimalChange(current, text)]);
54
59
  }
55
60
  /*
56
61
  * Applies `didChange` entries in order, which a server that negotiated incremental sync gets as
57
- * they are. The version goes up by one even when the text ends up the same.
62
+ * they are. The version goes up by one even when the text ends up the same. A server with full
63
+ * sync gets the whole text once the changes pause, or before the next request, save or close.
58
64
  */
59
65
  applyChanges(changes) {
60
66
  this.assertOpen();
@@ -62,17 +68,18 @@ export class LspDocument {
62
68
  if (!kind) {
63
69
  return Promise.reject(new LspError('Server did not negotiate document changes', ErrorCodes.MethodNotFound));
64
70
  }
65
- let next;
66
71
  try {
67
- next = applyContentChanges(this.content, changes);
72
+ this.content.applyChanges(changes);
68
73
  }
69
74
  catch (error) {
70
75
  return Promise.reject(error);
71
76
  }
72
- this.content = next;
73
77
  const version = ++this.revision;
74
78
  this.cancelRequests();
75
- const contentChanges = kind === 1 ? [{ text: next }] : changes.map((change) => ({ ...change }));
79
+ if (kind === 1) {
80
+ return this.queueFullText();
81
+ }
82
+ const contentChanges = changes.map((change) => ({ ...change }));
76
83
  this.synced = this.synced.then(() => this.session.connection.notify('textDocument/didChange', { textDocument: { uri: this.uri, version }, contentChanges }));
77
84
  void this.synced.catch((error) => this.session.connection.reportError(error));
78
85
  return this.synced;
@@ -82,6 +89,7 @@ export class LspDocument {
82
89
  this.assertOpen();
83
90
  const version = this.version;
84
91
  const text = this.text;
92
+ this.sendFullText?.();
85
93
  await this.synced;
86
94
  this.assertCurrent(version);
87
95
  const save = this.session.syncOptions.save;
@@ -201,6 +209,7 @@ export class LspDocument {
201
209
  }
202
210
  this.closed = true;
203
211
  this.cancelRequests();
212
+ this.sendFullText?.();
204
213
  this.closePromise = this.synced
205
214
  .then(async () => {
206
215
  if (!this.session.connection.isClosed && this.session.syncOptions.openClose) {
@@ -214,6 +223,7 @@ export class LspDocument {
214
223
  disconnect() {
215
224
  this.closed = true;
216
225
  this.cancelRequests();
226
+ this.sendFullText?.();
217
227
  this.session.forgetDocument(this);
218
228
  }
219
229
  feature(method, params, options) {
@@ -244,6 +254,7 @@ export class LspDocument {
244
254
  abort();
245
255
  }
246
256
  try {
257
+ this.sendFullText?.();
247
258
  await this.synced;
248
259
  this.assertCurrent(version);
249
260
  const result = await this.session.request(method, params, { signal: controller.signal, timeoutMs: options.timeoutMs });
@@ -288,6 +299,32 @@ export class LspDocument {
288
299
  throw new StaleResultError(this.uri);
289
300
  }
290
301
  }
302
+ /* Changes that arrive while the whole text waits go out with it, under the newest version. */
303
+ queueFullText() {
304
+ if (this.sendFullText) {
305
+ return this.synced;
306
+ }
307
+ const due = new Promise((resolve) => {
308
+ const timer = setTimeout(resolve, this.session.options.fullSyncDelayMs ?? FULL_SYNC_DELAY_MS);
309
+ this.sendFullText = () => {
310
+ clearTimeout(timer);
311
+ resolve();
312
+ };
313
+ });
314
+ this.synced = this.synced
315
+ .then(() => due)
316
+ .then(async () => {
317
+ this.sendFullText = undefined;
318
+ if (!this.session.connection.isClosed) {
319
+ await this.session.connection.notify('textDocument/didChange', {
320
+ textDocument: { uri: this.uri, version: this.revision },
321
+ contentChanges: [{ text: this.content.text }]
322
+ });
323
+ }
324
+ });
325
+ void this.synced.catch((error) => this.session.connection.reportError(error));
326
+ return this.synced;
327
+ }
291
328
  cancelRequests() {
292
329
  for (const controller of this.inFlight) {
293
330
  controller.abort();
package/dist/edits.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { ErrorCodes, LspError } from './connection.js';
2
2
  const CR = 13;
3
3
  const LF = 10;
4
- /* A line ends at `\n`, `\r\n` or a lone `\r`, which is how the protocol counts lines. */
5
- export function offsetAt(text, position) {
4
+ function checkPosition(position) {
6
5
  if (!Number.isInteger(position.line) || !Number.isInteger(position.character) || position.line < 0 || position.character < 0) {
7
6
  throw new LspError('Invalid edit position');
8
7
  }
8
+ }
9
+ /* A line ends at `\n`, `\r\n` or a lone `\r`, which is how the protocol counts lines. */
10
+ export function offsetAt(text, position) {
11
+ checkPosition(position);
9
12
  let line = 0;
10
13
  let offset = 0;
11
14
  while (line < position.line && offset < text.length) {
@@ -64,12 +67,40 @@ export function positionAt(text, offset) {
64
67
  export function endPosition(text) {
65
68
  return positionAt(text, text.length);
66
69
  }
70
+ /* `offsetAt` for many positions in one text: the starts of its lines are found once instead of on every call. */
71
+ function offsetsIn(text) {
72
+ const starts = [0];
73
+ for (let i = 0; i < text.length; i++) {
74
+ const code = text.charCodeAt(i);
75
+ if (code === CR && text.charCodeAt(i + 1) === LF) {
76
+ i++;
77
+ starts.push(i + 1);
78
+ }
79
+ else if (code === CR || code === LF) {
80
+ starts.push(i + 1);
81
+ }
82
+ }
83
+ return (position) => {
84
+ checkPosition(position);
85
+ const start = starts[position.line];
86
+ if (start === undefined) {
87
+ throw new LspError('Edit line is outside the document');
88
+ }
89
+ const next = starts[position.line + 1];
90
+ const end = next === undefined ? text.length : next - (text.charCodeAt(next - 1) === LF && text.charCodeAt(next - 2) === CR ? 2 : 1);
91
+ if (start + position.character > end) {
92
+ throw new LspError('Edit character is outside the line');
93
+ }
94
+ return start + position.character;
95
+ };
96
+ }
67
97
  /* Edits are simultaneous and in the coordinates of `text`; two inserts at one place keep their order. */
68
98
  export function applyTextEdits(text, edits) {
99
+ const offsetOf = offsetsIn(text);
69
100
  const spans = edits
70
101
  .map((edit, index) => ({
71
- start: offsetAt(text, edit.range.start),
72
- end: offsetAt(text, edit.range.end),
102
+ start: offsetOf(edit.range.start),
103
+ end: offsetOf(edit.range.end),
73
104
  text: edit.newText,
74
105
  index
75
106
  }))
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { planWorkspaceOperations, type PlannedCreateStep, type PlannedDeleteStep
6
6
  export { createsTaken, deletesTaken, renamesTaken, type CreatedFile, type DeletedFile, type RenamedFile } from './file-operations.ts';
7
7
  export { watchesFile, type FileChangeType } from './watched-files.ts';
8
8
  export { LspSession, type LspSessionOptions, type SessionState } from './session.ts';
9
+ export { TextMirror } from './text-mirror.ts';
9
10
  export type { DiagnosticsReport, LanguageDocument, LanguageRequestOptions, LanguageService } from './service.ts';
10
11
  export { ContentLengthDecoder, connectWebSocket, createMemoryTransportPair, createStreamTransport, encodeMessage, type ByteStream, type WebSocketTransportOptions } from './transport.ts';
11
12
  export { globMatch } from './glob.ts';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { planWorkspaceOperations } from './workspace-operations.js';
6
6
  export { createsTaken, deletesTaken, renamesTaken } from './file-operations.js';
7
7
  export { watchesFile } from './watched-files.js';
8
8
  export { LspSession } from './session.js';
9
+ export { TextMirror } from './text-mirror.js';
9
10
  export { ContentLengthDecoder, connectWebSocket, createMemoryTransportPair, createStreamTransport, encodeMessage } from './transport.js';
10
11
  export { globMatch } from './glob.js';
11
12
  export { fileUriToPath, pathToFileUri } from './uris.js';
package/dist/session.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface LspSessionOptions {
14
14
  version?: string;
15
15
  };
16
16
  timeoutMs?: number;
17
+ fullSyncDelayMs?: number;
17
18
  snippetSupport?: boolean;
18
19
  insertTextModes?: readonly (1 | 2)[];
19
20
  completionItemDefaults?: readonly string[];
@@ -1,4 +1,4 @@
1
- import { LspSession } from './session.ts';
1
+ import { LspSession, type LspSessionOptions } from './session.ts';
2
2
  import type { LspTransport, RpcMessage, ServerCapabilities } from './protocol.ts';
3
3
  export declare class ScriptedTransport implements LspTransport {
4
4
  sent: RpcMessage[];
@@ -26,7 +26,7 @@ export declare const origin: {
26
26
  line: number;
27
27
  character: number;
28
28
  };
29
- export declare function sessionWith(capabilities?: ServerCapabilities): Promise<{
29
+ export declare function sessionWith(capabilities?: ServerCapabilities, options?: LspSessionOptions): Promise<{
30
30
  transport: ScriptedTransport;
31
31
  session: LspSession;
32
32
  }>;
@@ -57,10 +57,10 @@ export async function flush() {
57
57
  }
58
58
  }
59
59
  export const origin = { line: 0, character: 0 };
60
- export async function sessionWith(capabilities = {}) {
60
+ export async function sessionWith(capabilities = {}, options) {
61
61
  const transport = new ScriptedTransport();
62
62
  transport.autoInitialize = { textDocumentSync: 2, ...capabilities };
63
- const session = new LspSession(transport);
63
+ const session = new LspSession(transport, options);
64
64
  await session.initialize();
65
65
  return { transport, session };
66
66
  }
@@ -0,0 +1,12 @@
1
+ import type { ContentChange } from './protocol.ts';
2
+ export declare class TextMirror {
3
+ private lines;
4
+ private joined;
5
+ constructor(text?: string);
6
+ get text(): string;
7
+ get lineCount(): number;
8
+ applyChanges(changes: readonly ContentChange[]): void;
9
+ private applyRange;
10
+ private lineAt;
11
+ private replace;
12
+ }
@@ -0,0 +1,118 @@
1
+ import { LspError } from './connection.js';
2
+ const CR = 13;
3
+ const LF = 10;
4
+ /* Past this many lines a spread into `splice` could exceed the engine's argument limit. */
5
+ const SPREAD_LIMIT = 10_000;
6
+ /* Each line keeps its own break, so the last one is the only line without. */
7
+ function splitLines(text) {
8
+ const lines = [];
9
+ let start = 0;
10
+ for (let i = 0; i < text.length; i++) {
11
+ const code = text.charCodeAt(i);
12
+ if (code === CR || code === LF) {
13
+ if (code === CR && text.charCodeAt(i + 1) === LF) {
14
+ i++;
15
+ }
16
+ lines.push(text.slice(start, i + 1));
17
+ start = i + 1;
18
+ }
19
+ }
20
+ lines.push(text.slice(start));
21
+ return lines;
22
+ }
23
+ function breakLength(line) {
24
+ const last = line.charCodeAt(line.length - 1);
25
+ if (last === LF) {
26
+ return line.charCodeAt(line.length - 2) === CR ? 2 : 1;
27
+ }
28
+ return last === CR ? 1 : 0;
29
+ }
30
+ /*
31
+ * The text of a document as its lines, kept up to date with `didChange` entries. A change costs the
32
+ * lines it touches instead of the whole text, and the text is joined only when it is read.
33
+ */
34
+ export class TextMirror {
35
+ lines;
36
+ joined;
37
+ constructor(text = '') {
38
+ this.lines = splitLines(text);
39
+ this.joined = text;
40
+ }
41
+ get text() {
42
+ this.joined ??= this.lines.join('');
43
+ return this.joined;
44
+ }
45
+ get lineCount() {
46
+ return this.lines.length;
47
+ }
48
+ /* Applies the entries in order, each in the text the one before it left. When one does not fit, it throws and the text stays as it was. */
49
+ applyChanges(changes) {
50
+ const applied = [];
51
+ try {
52
+ for (const change of changes) {
53
+ applied.push(change.range
54
+ ? this.applyRange(change.range.start, change.range.end, change.text)
55
+ : this.replace(0, this.lines.length, splitLines(change.text)));
56
+ this.joined = change.range ? null : change.text;
57
+ }
58
+ }
59
+ catch (error) {
60
+ for (const splice of applied.reverse()) {
61
+ this.replace(splice.start, splice.inserted, splice.removed);
62
+ }
63
+ this.joined = null;
64
+ throw error;
65
+ }
66
+ }
67
+ applyRange(start, end, text) {
68
+ const startLine = this.lineAt(start);
69
+ const endLine = this.lineAt(end);
70
+ if (end.line < start.line || (end.line === start.line && end.character < start.character)) {
71
+ throw new LspError('Change range is reversed');
72
+ }
73
+ const inserted = splitLines(text);
74
+ const last = inserted.length - 1;
75
+ let suffix = endLine.slice(end.character);
76
+ // An inserted `\r` and the `\n` after the range become one break.
77
+ if (text.charCodeAt(text.length - 1) === CR && suffix.charCodeAt(0) === LF) {
78
+ inserted[last - 1] += '\n';
79
+ suffix = suffix.slice(1);
80
+ }
81
+ inserted[last] += suffix;
82
+ inserted[0] = startLine.slice(0, start.character) + inserted[0];
83
+ if (inserted[last] === '' && end.line < this.lines.length - 1) {
84
+ inserted.pop();
85
+ }
86
+ let first = start.line;
87
+ // A line that ends in a lone `\r` takes a `\n` that now follows it as the rest of its break.
88
+ if (inserted[0] === '\n' && first > 0 && this.lines[first - 1].charCodeAt(this.lines[first - 1].length - 1) === CR) {
89
+ first--;
90
+ inserted[0] = this.lines[first] + '\n';
91
+ }
92
+ return this.replace(first, end.line - first + 1, inserted);
93
+ }
94
+ 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
+ }
98
+ const line = this.lines[position.line];
99
+ if (line === undefined) {
100
+ throw new LspError('Edit line is outside the document');
101
+ }
102
+ if (position.character > line.length - breakLength(line)) {
103
+ throw new LspError('Edit character is outside the line');
104
+ }
105
+ return line;
106
+ }
107
+ replace(start, count, lines) {
108
+ let removed;
109
+ if (lines.length <= SPREAD_LIMIT) {
110
+ removed = this.lines.splice(start, count, ...lines);
111
+ }
112
+ else {
113
+ removed = this.lines.slice(start, start + count);
114
+ this.lines = [...this.lines.slice(0, start), ...lines, ...this.lines.slice(start + count)];
115
+ }
116
+ return { start, removed, inserted: lines.length };
117
+ }
118
+ }
package/dist/transport.js CHANGED
@@ -191,11 +191,14 @@ export function createMemoryTransportPair() {
191
191
  return [end(left, right), end(right, left)];
192
192
  }
193
193
  const MAX_SOCKET_BUFFER_BYTES = 8 * 1024 * 1024;
194
+ /* A socket has no event for a drained buffer, so a send that waits looks again this often. */
195
+ const DRAIN_POLL_MS = 10;
194
196
  /* JSON text frames over a WebSocket, which a browser can open and a daemon can serve. */
195
197
  export async function connectWebSocket(url, options = {}) {
196
198
  if (options.signal?.aborted) {
197
199
  throw new Error('WebSocket connection cancelled');
198
200
  }
201
+ const timeoutMs = options.timeoutMs ?? 10_000;
199
202
  const socket = options.createWebSocket ? options.createWebSocket(url, options.protocols) : new WebSocket(url, options.protocols);
200
203
  const events = new TransportEvents();
201
204
  socket.addEventListener('message', (event) => {
@@ -213,7 +216,7 @@ export async function connectWebSocket(url, options = {}) {
213
216
  socket.addEventListener('close', (event) => events.finish(event.code === 1000 ? undefined : new Error(`LSP WebSocket closed (${event.code})`)));
214
217
  socket.addEventListener('error', () => events.finish(new Error('LSP WebSocket failed')));
215
218
  await new Promise((resolve, reject) => {
216
- const timer = setTimeout(() => fail(new Error('LSP WebSocket connection timed out')), options.timeoutMs ?? 10_000);
219
+ const timer = setTimeout(() => fail(new Error('LSP WebSocket connection timed out')), timeoutMs);
217
220
  const cleanup = () => {
218
221
  clearTimeout(timer);
219
222
  socket.removeEventListener('open', open);
@@ -241,13 +244,17 @@ export async function connectWebSocket(url, options = {}) {
241
244
  return {
242
245
  onMessage: (listener) => events.onMessage(listener),
243
246
  onClose: (listener) => events.onClose(listener),
244
- send(message) {
247
+ async send(message) {
248
+ const deadline = Date.now() + timeoutMs;
249
+ while (!events.closed && socket.readyState === 1 && socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) {
250
+ if (Date.now() >= deadline) {
251
+ throw new Error('LSP WebSocket send buffer is full');
252
+ }
253
+ await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
254
+ }
245
255
  if (events.closed || socket.readyState !== 1) {
246
256
  throw new Error('LSP WebSocket is closed');
247
257
  }
248
- if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) {
249
- throw new Error('LSP WebSocket send buffer is full');
250
- }
251
258
  socket.send(JSON.stringify(message));
252
259
  },
253
260
  close() {
package/dist/uris.js CHANGED
@@ -1,18 +1,26 @@
1
1
  function isWindowsDrive(segment) {
2
2
  return /^[A-Za-z]:$/.test(segment);
3
3
  }
4
- /* An absolute path, POSIX or with a Windows drive, as the `file:` URI a language server names it by. */
4
+ /* An absolute path, POSIX, with a Windows drive or UNC, as the `file:` URI a language server names it by. */
5
5
  export function pathToFileUri(path) {
6
- const normalized = path.replaceAll('\\', '/');
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('\\\\');
8
+ const normalized = windows ? path.replaceAll('\\', '/') : path;
9
+ if (windows && normalized.startsWith('//')) {
10
+ const [host = '', ...segments] = normalized.slice(2).split('/');
11
+ return `file://${encodeURIComponent(host)}/${segments.map(encodeURIComponent).join('/')}`;
12
+ }
7
13
  const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`;
8
14
  const segments = rooted.split('/').map((segment, index) => (index === 1 && isWindowsDrive(segment) ? segment : encodeURIComponent(segment)));
9
15
  return `file://${segments.join('/')}`;
10
16
  }
11
- /* The path behind a `file:` URI, in the form of the machine it names, or null for any other kind of URI. */
17
+ /* The path behind a `file:` URI, in the form of the machine it names, or null for any other kind of URI or one that does not decode. */
12
18
  export function fileUriToPath(uri) {
13
19
  let url;
20
+ let path;
14
21
  try {
15
22
  url = new URL(uri);
23
+ path = decodeURIComponent(url.pathname);
16
24
  }
17
25
  catch {
18
26
  return null;
@@ -20,7 +28,10 @@ export function fileUriToPath(uri) {
20
28
  if (url.protocol !== 'file:') {
21
29
  return null;
22
30
  }
23
- const path = decodeURIComponent(url.pathname);
31
+ // A server in the URI names a UNC share, which only Windows reads as a path.
32
+ if (url.hostname !== '' && url.hostname !== 'localhost') {
33
+ return `\\\\${url.hostname}${path.replaceAll('/', '\\')}`;
34
+ }
24
35
  if (/^\/[A-Za-z]:/.test(path)) {
25
36
  return path.slice(1).replaceAll('/', '\\');
26
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/lsp",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/document.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ErrorCodes, LspError, StaleResultError } from './connection.ts';
2
- import { applyContentChanges, minimalChange } from './edits.ts';
2
+ import { minimalChange } from './edits.ts';
3
3
  import type { LspSession } from './session.ts';
4
+ import { TextMirror } from './text-mirror.ts';
4
5
  import type {
5
6
  CallHierarchyIncomingCall,
6
7
  CallHierarchyItem,
@@ -36,6 +37,9 @@ import type {
36
37
  WorkspaceEdit
37
38
  } from './protocol.ts';
38
39
 
40
+ /* The delay of the VS Code language client for servers with full sync. */
41
+ const FULL_SYNC_DELAY_MS = 200;
42
+
39
43
  /*
40
44
  * One open text document in one session. It owns the version: every change raises it by one, and a
41
45
  * result that arrives for an older version is refused, so nothing computed for a text that is gone
@@ -47,9 +51,10 @@ export class LspDocument {
47
51
  readonly languageId: string;
48
52
  readonly ready: Promise<void>;
49
53
  private revision: number;
50
- private content: string;
54
+ private readonly content: TextMirror;
51
55
  private closed = false;
52
56
  private synced: Promise<void>;
57
+ private sendFullText?: () => void;
53
58
  private closePromise?: Promise<void>;
54
59
  private readonly inFlight = new Set<AbortController>();
55
60
  private readonly latest = new Map<string, AbortController>();
@@ -61,7 +66,7 @@ export class LspDocument {
61
66
  this.session = session;
62
67
  this.uri = item.uri;
63
68
  this.languageId = item.languageId;
64
- this.content = item.text;
69
+ this.content = new TextMirror(item.text);
65
70
  this.revision = item.version ?? 1;
66
71
  if (!Number.isSafeInteger(this.revision) || this.revision < 0) {
67
72
  throw new LspError('Invalid document version');
@@ -78,7 +83,7 @@ export class LspDocument {
78
83
  }
79
84
 
80
85
  get text(): string {
81
- return this.content;
86
+ return this.content.text;
82
87
  }
83
88
 
84
89
  get isClosed(): boolean {
@@ -88,15 +93,17 @@ export class LspDocument {
88
93
  /* Sends the difference to the text as the one range that changed, or the whole text to a server that wants no ranges. */
89
94
  updateText(text: string): Promise<void> {
90
95
  this.assertOpen();
91
- if (text === this.content) {
96
+ const current = this.content.text;
97
+ if (text === current) {
92
98
  return this.synced;
93
99
  }
94
- return this.applyChanges([minimalChange(this.content, text)]);
100
+ return this.applyChanges([minimalChange(current, text)]);
95
101
  }
96
102
 
97
103
  /*
98
104
  * Applies `didChange` entries in order, which a server that negotiated incremental sync gets as
99
- * they are. The version goes up by one even when the text ends up the same.
105
+ * they are. The version goes up by one even when the text ends up the same. A server with full
106
+ * sync gets the whole text once the changes pause, or before the next request, save or close.
100
107
  */
101
108
  applyChanges(changes: readonly ContentChange[]): Promise<void> {
102
109
  this.assertOpen();
@@ -104,16 +111,17 @@ export class LspDocument {
104
111
  if (!kind) {
105
112
  return Promise.reject(new LspError('Server did not negotiate document changes', ErrorCodes.MethodNotFound));
106
113
  }
107
- let next: string;
108
114
  try {
109
- next = applyContentChanges(this.content, changes);
115
+ this.content.applyChanges(changes);
110
116
  } catch (error) {
111
117
  return Promise.reject(error);
112
118
  }
113
- this.content = next;
114
119
  const version = ++this.revision;
115
120
  this.cancelRequests();
116
- const contentChanges = kind === 1 ? [{ text: next }] : changes.map((change) => ({ ...change }));
121
+ if (kind === 1) {
122
+ return this.queueFullText();
123
+ }
124
+ const contentChanges = changes.map((change) => ({ ...change }));
117
125
  this.synced = this.synced.then(() =>
118
126
  this.session.connection.notify('textDocument/didChange', { textDocument: { uri: this.uri, version }, contentChanges })
119
127
  );
@@ -126,6 +134,7 @@ export class LspDocument {
126
134
  this.assertOpen();
127
135
  const version = this.version;
128
136
  const text = this.text;
137
+ this.sendFullText?.();
129
138
  await this.synced;
130
139
  this.assertCurrent(version);
131
140
  const save = this.session.syncOptions.save;
@@ -278,6 +287,7 @@ export class LspDocument {
278
287
  }
279
288
  this.closed = true;
280
289
  this.cancelRequests();
290
+ this.sendFullText?.();
281
291
  this.closePromise = this.synced
282
292
  .then(async () => {
283
293
  if (!this.session.connection.isClosed && this.session.syncOptions.openClose) {
@@ -292,6 +302,7 @@ export class LspDocument {
292
302
  disconnect(): void {
293
303
  this.closed = true;
294
304
  this.cancelRequests();
305
+ this.sendFullText?.();
295
306
  this.session.forgetDocument(this);
296
307
  }
297
308
 
@@ -325,6 +336,7 @@ export class LspDocument {
325
336
  abort();
326
337
  }
327
338
  try {
339
+ this.sendFullText?.();
328
340
  await this.synced;
329
341
  this.assertCurrent(version);
330
342
  const result = await this.session.request<T>(method, params, { signal: controller.signal, timeoutMs: options.timeoutMs });
@@ -370,6 +382,33 @@ export class LspDocument {
370
382
  }
371
383
  }
372
384
 
385
+ /* Changes that arrive while the whole text waits go out with it, under the newest version. */
386
+ private queueFullText(): Promise<void> {
387
+ if (this.sendFullText) {
388
+ return this.synced;
389
+ }
390
+ const due = new Promise<void>((resolve) => {
391
+ const timer = setTimeout(resolve, this.session.options.fullSyncDelayMs ?? FULL_SYNC_DELAY_MS);
392
+ this.sendFullText = () => {
393
+ clearTimeout(timer);
394
+ resolve();
395
+ };
396
+ });
397
+ this.synced = this.synced
398
+ .then(() => due)
399
+ .then(async () => {
400
+ this.sendFullText = undefined;
401
+ if (!this.session.connection.isClosed) {
402
+ await this.session.connection.notify('textDocument/didChange', {
403
+ textDocument: { uri: this.uri, version: this.revision },
404
+ contentChanges: [{ text: this.content.text }]
405
+ });
406
+ }
407
+ });
408
+ void this.synced.catch((error) => this.session.connection.reportError(error));
409
+ return this.synced;
410
+ }
411
+
373
412
  private cancelRequests(): void {
374
413
  for (const controller of this.inFlight) {
375
414
  controller.abort();
package/src/edits.ts CHANGED
@@ -18,11 +18,15 @@ export interface PlannedDocumentEdit {
18
18
  const CR = 13;
19
19
  const LF = 10;
20
20
 
21
- /* A line ends at `\n`, `\r\n` or a lone `\r`, which is how the protocol counts lines. */
22
- export function offsetAt(text: string, position: Position): number {
21
+ function checkPosition(position: Position): void {
23
22
  if (!Number.isInteger(position.line) || !Number.isInteger(position.character) || position.line < 0 || position.character < 0) {
24
23
  throw new LspError('Invalid edit position');
25
24
  }
25
+ }
26
+
27
+ /* A line ends at `\n`, `\r\n` or a lone `\r`, which is how the protocol counts lines. */
28
+ export function offsetAt(text: string, position: Position): number {
29
+ checkPosition(position);
26
30
  let line = 0;
27
31
  let offset = 0;
28
32
  while (line < position.line && offset < text.length) {
@@ -81,12 +85,40 @@ export function endPosition(text: string): Position {
81
85
  return positionAt(text, text.length);
82
86
  }
83
87
 
88
+ /* `offsetAt` for many positions in one text: the starts of its lines are found once instead of on every call. */
89
+ function offsetsIn(text: string): (position: Position) => number {
90
+ const starts = [0];
91
+ for (let i = 0; i < text.length; i++) {
92
+ const code = text.charCodeAt(i);
93
+ if (code === CR && text.charCodeAt(i + 1) === LF) {
94
+ i++;
95
+ starts.push(i + 1);
96
+ } else if (code === CR || code === LF) {
97
+ starts.push(i + 1);
98
+ }
99
+ }
100
+ return (position) => {
101
+ checkPosition(position);
102
+ const start = starts[position.line];
103
+ if (start === undefined) {
104
+ throw new LspError('Edit line is outside the document');
105
+ }
106
+ const next = starts[position.line + 1];
107
+ const end = next === undefined ? text.length : next - (text.charCodeAt(next - 1) === LF && text.charCodeAt(next - 2) === CR ? 2 : 1);
108
+ if (start + position.character > end) {
109
+ throw new LspError('Edit character is outside the line');
110
+ }
111
+ return start + position.character;
112
+ };
113
+ }
114
+
84
115
  /* Edits are simultaneous and in the coordinates of `text`; two inserts at one place keep their order. */
85
116
  export function applyTextEdits(text: string, edits: readonly TextEdit[]): string {
117
+ const offsetOf = offsetsIn(text);
86
118
  const spans = edits
87
119
  .map((edit, index) => ({
88
- start: offsetAt(text, edit.range.start),
89
- end: offsetAt(text, edit.range.end),
120
+ start: offsetOf(edit.range.start),
121
+ end: offsetOf(edit.range.end),
90
122
  text: edit.newText,
91
123
  index
92
124
  }))
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  export { createsTaken, deletesTaken, renamesTaken, type CreatedFile, type DeletedFile, type RenamedFile } from './file-operations.ts';
28
28
  export { watchesFile, type FileChangeType } from './watched-files.ts';
29
29
  export { LspSession, type LspSessionOptions, type SessionState } from './session.ts';
30
+ export { TextMirror } from './text-mirror.ts';
30
31
  export type { DiagnosticsReport, LanguageDocument, LanguageRequestOptions, LanguageService } from './service.ts';
31
32
  export {
32
33
  ContentLengthDecoder,
package/src/session.ts CHANGED
@@ -39,6 +39,8 @@ export interface LspSessionOptions {
39
39
  configuration?: Record<string, unknown>;
40
40
  clientInfo?: { name: string; version?: string };
41
41
  timeoutMs?: number;
42
+ /* How long the changes for a server with full sync gather before the whole text goes out; 200 by default. */
43
+ fullSyncDelayMs?: number;
42
44
  snippetSupport?: boolean;
43
45
  insertTextModes?: readonly (1 | 2)[];
44
46
  completionItemDefaults?: readonly string[];
@@ -1,4 +1,4 @@
1
- import { LspSession } from './session.ts';
1
+ import { LspSession, type LspSessionOptions } from './session.ts';
2
2
  import type { LspTransport, RpcMessage, ServerCapabilities } from './protocol.ts';
3
3
 
4
4
  /* A transport that records what is sent and lets a test say what comes back, for tests that assert on the exact wire. */
@@ -73,10 +73,10 @@ export async function flush(): Promise<void> {
73
73
 
74
74
  export const origin = { line: 0, character: 0 };
75
75
 
76
- export async function sessionWith(capabilities: ServerCapabilities = {}) {
76
+ export async function sessionWith(capabilities: ServerCapabilities = {}, options?: LspSessionOptions) {
77
77
  const transport = new ScriptedTransport();
78
78
  transport.autoInitialize = { textDocumentSync: 2, ...capabilities };
79
- const session = new LspSession(transport);
79
+ const session = new LspSession(transport, options);
80
80
  await session.initialize();
81
81
  return { transport, session };
82
82
  }
@@ -0,0 +1,137 @@
1
+ import { LspError } from './connection.ts';
2
+ import type { ContentChange, Position } from './protocol.ts';
3
+
4
+ const CR = 13;
5
+ const LF = 10;
6
+
7
+ /* Past this many lines a spread into `splice` could exceed the engine's argument limit. */
8
+ const SPREAD_LIMIT = 10_000;
9
+
10
+ /* Each line keeps its own break, so the last one is the only line without. */
11
+ function splitLines(text: string): string[] {
12
+ const lines: string[] = [];
13
+ let start = 0;
14
+ for (let i = 0; i < text.length; i++) {
15
+ const code = text.charCodeAt(i);
16
+ if (code === CR || code === LF) {
17
+ if (code === CR && text.charCodeAt(i + 1) === LF) {
18
+ i++;
19
+ }
20
+ lines.push(text.slice(start, i + 1));
21
+ start = i + 1;
22
+ }
23
+ }
24
+ lines.push(text.slice(start));
25
+ return lines;
26
+ }
27
+
28
+ function breakLength(line: string): number {
29
+ const last = line.charCodeAt(line.length - 1);
30
+ if (last === LF) {
31
+ return line.charCodeAt(line.length - 2) === CR ? 2 : 1;
32
+ }
33
+ return last === CR ? 1 : 0;
34
+ }
35
+
36
+ interface Splice {
37
+ start: number;
38
+ removed: string[];
39
+ inserted: number;
40
+ }
41
+
42
+ /*
43
+ * The text of a document as its lines, kept up to date with `didChange` entries. A change costs the
44
+ * lines it touches instead of the whole text, and the text is joined only when it is read.
45
+ */
46
+ export class TextMirror {
47
+ private lines: string[];
48
+ private joined: string | null;
49
+
50
+ constructor(text = '') {
51
+ this.lines = splitLines(text);
52
+ this.joined = text;
53
+ }
54
+
55
+ get text(): string {
56
+ this.joined ??= this.lines.join('');
57
+ return this.joined;
58
+ }
59
+
60
+ get lineCount(): number {
61
+ return this.lines.length;
62
+ }
63
+
64
+ /* Applies the entries in order, each in the text the one before it left. When one does not fit, it throws and the text stays as it was. */
65
+ applyChanges(changes: readonly ContentChange[]): void {
66
+ const applied: Splice[] = [];
67
+ try {
68
+ for (const change of changes) {
69
+ applied.push(
70
+ change.range
71
+ ? this.applyRange(change.range.start, change.range.end, change.text)
72
+ : this.replace(0, this.lines.length, splitLines(change.text))
73
+ );
74
+ this.joined = change.range ? null : change.text;
75
+ }
76
+ } catch (error) {
77
+ for (const splice of applied.reverse()) {
78
+ this.replace(splice.start, splice.inserted, splice.removed);
79
+ }
80
+ this.joined = null;
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ private applyRange(start: Position, end: Position, text: string): Splice {
86
+ const startLine = this.lineAt(start);
87
+ const endLine = this.lineAt(end);
88
+ if (end.line < start.line || (end.line === start.line && end.character < start.character)) {
89
+ throw new LspError('Change range is reversed');
90
+ }
91
+ const inserted = splitLines(text);
92
+ const last = inserted.length - 1;
93
+ let suffix = endLine.slice(end.character);
94
+ // An inserted `\r` and the `\n` after the range become one break.
95
+ if (text.charCodeAt(text.length - 1) === CR && suffix.charCodeAt(0) === LF) {
96
+ inserted[last - 1] += '\n';
97
+ suffix = suffix.slice(1);
98
+ }
99
+ inserted[last] += suffix;
100
+ inserted[0] = startLine.slice(0, start.character) + inserted[0];
101
+ if (inserted[last] === '' && end.line < this.lines.length - 1) {
102
+ inserted.pop();
103
+ }
104
+ let first = start.line;
105
+ // A line that ends in a lone `\r` takes a `\n` that now follows it as the rest of its break.
106
+ if (inserted[0] === '\n' && first > 0 && this.lines[first - 1].charCodeAt(this.lines[first - 1].length - 1) === CR) {
107
+ first--;
108
+ inserted[0] = this.lines[first] + '\n';
109
+ }
110
+ return this.replace(first, end.line - first + 1, inserted);
111
+ }
112
+
113
+ private lineAt(position: Position): string {
114
+ if (!Number.isInteger(position.line) || !Number.isInteger(position.character) || position.line < 0 || position.character < 0) {
115
+ throw new LspError('Invalid edit position');
116
+ }
117
+ const line = this.lines[position.line];
118
+ if (line === undefined) {
119
+ throw new LspError('Edit line is outside the document');
120
+ }
121
+ if (position.character > line.length - breakLength(line)) {
122
+ throw new LspError('Edit character is outside the line');
123
+ }
124
+ return line;
125
+ }
126
+
127
+ private replace(start: number, count: number, lines: string[]): Splice {
128
+ let removed: string[];
129
+ if (lines.length <= SPREAD_LIMIT) {
130
+ removed = this.lines.splice(start, count, ...lines);
131
+ } else {
132
+ removed = this.lines.slice(start, start + count);
133
+ this.lines = [...this.lines.slice(0, start), ...lines, ...this.lines.slice(start + count)];
134
+ }
135
+ return { start, removed, inserted: lines.length };
136
+ }
137
+ }
package/src/transport.ts CHANGED
@@ -218,17 +218,21 @@ export function createMemoryTransportPair(): [LspTransport, LspTransport] {
218
218
  export interface WebSocketTransportOptions {
219
219
  protocols?: string[];
220
220
  signal?: AbortSignal;
221
+ /* How long the socket may take to open, and how long a send waits for a full buffer to drain. */
221
222
  timeoutMs?: number;
222
223
  createWebSocket?: (url: string, protocols?: string[]) => WebSocket;
223
224
  }
224
225
 
225
226
  const MAX_SOCKET_BUFFER_BYTES = 8 * 1024 * 1024;
227
+ /* A socket has no event for a drained buffer, so a send that waits looks again this often. */
228
+ const DRAIN_POLL_MS = 10;
226
229
 
227
230
  /* JSON text frames over a WebSocket, which a browser can open and a daemon can serve. */
228
231
  export async function connectWebSocket(url: string, options: WebSocketTransportOptions = {}): Promise<LspTransport> {
229
232
  if (options.signal?.aborted) {
230
233
  throw new Error('WebSocket connection cancelled');
231
234
  }
235
+ const timeoutMs = options.timeoutMs ?? 10_000;
232
236
  const socket = options.createWebSocket ? options.createWebSocket(url, options.protocols) : new WebSocket(url, options.protocols);
233
237
  const events = new TransportEvents();
234
238
  socket.addEventListener('message', (event) => {
@@ -245,7 +249,7 @@ export async function connectWebSocket(url: string, options: WebSocketTransportO
245
249
  socket.addEventListener('close', (event) => events.finish(event.code === 1000 ? undefined : new Error(`LSP WebSocket closed (${event.code})`)));
246
250
  socket.addEventListener('error', () => events.finish(new Error('LSP WebSocket failed')));
247
251
  await new Promise<void>((resolve, reject) => {
248
- const timer = setTimeout(() => fail(new Error('LSP WebSocket connection timed out')), options.timeoutMs ?? 10_000);
252
+ const timer = setTimeout(() => fail(new Error('LSP WebSocket connection timed out')), timeoutMs);
249
253
  const cleanup = (): void => {
250
254
  clearTimeout(timer);
251
255
  socket.removeEventListener('open', open);
@@ -273,13 +277,17 @@ export async function connectWebSocket(url: string, options: WebSocketTransportO
273
277
  return {
274
278
  onMessage: (listener) => events.onMessage(listener),
275
279
  onClose: (listener) => events.onClose(listener),
276
- send(message) {
280
+ async send(message) {
281
+ const deadline = Date.now() + timeoutMs;
282
+ while (!events.closed && socket.readyState === 1 && socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) {
283
+ if (Date.now() >= deadline) {
284
+ throw new Error('LSP WebSocket send buffer is full');
285
+ }
286
+ await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS));
287
+ }
277
288
  if (events.closed || socket.readyState !== 1) {
278
289
  throw new Error('LSP WebSocket is closed');
279
290
  }
280
- if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) {
281
- throw new Error('LSP WebSocket send buffer is full');
282
- }
283
291
  socket.send(JSON.stringify(message));
284
292
  },
285
293
  close() {
package/src/uris.ts CHANGED
@@ -2,26 +2,37 @@ function isWindowsDrive(segment: string): boolean {
2
2
  return /^[A-Za-z]:$/.test(segment);
3
3
  }
4
4
 
5
- /* An absolute path, POSIX or with a Windows drive, as the `file:` URI a language server names it by. */
5
+ /* An absolute path, POSIX, with a Windows drive or UNC, as the `file:` URI a language server names it by. */
6
6
  export function pathToFileUri(path: string): string {
7
- const normalized = path.replaceAll('\\', '/');
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('\\\\');
9
+ const normalized = windows ? path.replaceAll('\\', '/') : path;
10
+ if (windows && normalized.startsWith('//')) {
11
+ const [host = '', ...segments] = normalized.slice(2).split('/');
12
+ return `file://${encodeURIComponent(host)}/${segments.map(encodeURIComponent).join('/')}`;
13
+ }
8
14
  const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`;
9
15
  const segments = rooted.split('/').map((segment, index) => (index === 1 && isWindowsDrive(segment) ? segment : encodeURIComponent(segment)));
10
16
  return `file://${segments.join('/')}`;
11
17
  }
12
18
 
13
- /* The path behind a `file:` URI, in the form of the machine it names, or null for any other kind of URI. */
19
+ /* The path behind a `file:` URI, in the form of the machine it names, or null for any other kind of URI or one that does not decode. */
14
20
  export function fileUriToPath(uri: string): string | null {
15
21
  let url: URL;
22
+ let path: string;
16
23
  try {
17
24
  url = new URL(uri);
25
+ path = decodeURIComponent(url.pathname);
18
26
  } catch {
19
27
  return null;
20
28
  }
21
29
  if (url.protocol !== 'file:') {
22
30
  return null;
23
31
  }
24
- const path = decodeURIComponent(url.pathname);
32
+ // A server in the URI names a UNC share, which only Windows reads as a path.
33
+ if (url.hostname !== '' && url.hostname !== 'localhost') {
34
+ return `\\\\${url.hostname}${path.replaceAll('/', '\\')}`;
35
+ }
25
36
  if (/^\/[A-Za-z]:/.test(path)) {
26
37
  return path.slice(1).replaceAll('/', '\\');
27
38
  }