@adecore/lsp 0.23.0 → 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.
- package/dist/document.d.ts +3 -1
- package/dist/document.js +47 -10
- package/dist/edits.js +35 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/session.d.ts +1 -0
- package/dist/test-transport.d.ts +2 -2
- package/dist/test-transport.js +2 -2
- package/dist/text-mirror.d.ts +12 -0
- package/dist/text-mirror.js +118 -0
- package/dist/transport.js +12 -5
- package/dist/uris.js +15 -4
- package/package.json +1 -1
- package/src/document.ts +50 -11
- package/src/edits.ts +36 -4
- package/src/index.ts +1 -0
- package/src/session.ts +2 -0
- package/src/test-transport.ts +3 -3
- package/src/text-mirror.ts +137 -0
- package/src/transport.ts +13 -5
- package/src/uris.ts +15 -4
package/dist/document.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
54
|
+
const current = this.content.text;
|
|
55
|
+
if (text === current) {
|
|
51
56
|
return this.synced;
|
|
52
57
|
}
|
|
53
|
-
return this.applyChanges([minimalChange(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
72
|
-
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
package/dist/test-transport.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/test-transport.js
CHANGED
|
@@ -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')),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/document.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ErrorCodes, LspError, StaleResultError } from './connection.ts';
|
|
2
|
-
import {
|
|
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:
|
|
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
|
-
|
|
96
|
+
const current = this.content.text;
|
|
97
|
+
if (text === current) {
|
|
92
98
|
return this.synced;
|
|
93
99
|
}
|
|
94
|
-
return this.applyChanges([minimalChange(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
89
|
-
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[];
|
package/src/test-transport.ts
CHANGED
|
@@ -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')),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|