@adecore/lsp 0.0.1 → 0.17.0-beta.2
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/LICENSE +110 -0
- package/README.md +47 -0
- package/dist/connection.d.ts +50 -0
- package/dist/connection.js +290 -0
- package/dist/document.d.ts +60 -0
- package/dist/document.js +282 -0
- package/dist/edits.d.ts +18 -0
- package/dist/edits.js +161 -0
- package/dist/file-operations.d.ts +5 -0
- package/dist/file-operations.js +23 -0
- package/dist/glob.d.ts +1 -0
- package/dist/glob.js +67 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/protocol.d.ts +470 -0
- package/dist/protocol.js +1 -0
- package/dist/service.d.ts +53 -0
- package/dist/service.js +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.js +528 -0
- package/dist/test-transport.d.ts +33 -0
- package/dist/test-transport.js +72 -0
- package/dist/testing.d.ts +33 -0
- package/dist/testing.js +72 -0
- package/dist/transport.d.ts +27 -0
- package/dist/transport.js +258 -0
- package/dist/uris.d.ts +2 -0
- package/dist/uris.js +28 -0
- package/dist/vue.d.ts +6 -0
- package/dist/vue.js +86 -0
- package/dist/watched-files.d.ts +3 -0
- package/dist/watched-files.js +41 -0
- package/package.json +36 -3
- package/src/connection.ts +310 -0
- package/src/document.ts +357 -0
- package/src/edits.ts +182 -0
- package/src/file-operations.ts +30 -0
- package/src/glob.ts +60 -0
- package/src/index.ts +30 -0
- package/src/protocol.ts +479 -0
- package/src/service.ts +109 -0
- package/src/session.ts +613 -0
- package/src/test-transport.ts +92 -0
- package/src/testing.ts +105 -0
- package/src/transport.ts +290 -0
- package/src/uris.ts +29 -0
- package/src/vue.ts +93 -0
- package/src/watched-files.ts +49 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { LspSession } from './session.ts';
|
|
2
|
+
import type { LspTransport, RpcMessage, ServerCapabilities } from './protocol.ts';
|
|
3
|
+
|
|
4
|
+
/* A transport that records what is sent and lets a test say what comes back, for tests that assert on the exact wire. */
|
|
5
|
+
export class ScriptedTransport implements LspTransport {
|
|
6
|
+
sent: RpcMessage[] = [];
|
|
7
|
+
messages = new Set<(message: unknown) => void>();
|
|
8
|
+
closes = new Set<(error?: Error) => void>();
|
|
9
|
+
autoInitialize: ServerCapabilities | undefined;
|
|
10
|
+
|
|
11
|
+
send(message: RpcMessage): void {
|
|
12
|
+
this.sent.push(message);
|
|
13
|
+
if ('method' in message && message.method === 'initialize' && 'id' in message && this.autoInitialize) {
|
|
14
|
+
queueMicrotask(() => this.emit({ jsonrpc: '2.0', id: message.id, result: { capabilities: this.autoInitialize } }));
|
|
15
|
+
}
|
|
16
|
+
if ('method' in message && message.method === 'shutdown' && 'id' in message) {
|
|
17
|
+
queueMicrotask(() => this.emit({ jsonrpc: '2.0', id: message.id, result: null }));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onMessage(listener: (message: unknown) => void) {
|
|
22
|
+
this.messages.add(listener);
|
|
23
|
+
return {
|
|
24
|
+
dispose: () => {
|
|
25
|
+
this.messages.delete(listener);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onClose(listener: (error?: Error) => void) {
|
|
31
|
+
this.closes.add(listener);
|
|
32
|
+
return {
|
|
33
|
+
dispose: () => {
|
|
34
|
+
this.closes.delete(listener);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
close() {
|
|
40
|
+
for (const listener of this.closes) {
|
|
41
|
+
listener();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
emit(message: RpcMessage) {
|
|
46
|
+
for (const listener of this.messages) {
|
|
47
|
+
listener(message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* The last request of `method` the client sent. */
|
|
52
|
+
request(method: string) {
|
|
53
|
+
return [...this.sent].reverse().find((message) => 'method' in message && message.method === method && 'id' in message) as {
|
|
54
|
+
id: number;
|
|
55
|
+
params: unknown;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
respond(method: string, result: unknown) {
|
|
60
|
+
this.emit({ jsonrpc: '2.0', id: this.request(method).id, result });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
methods(): string[] {
|
|
64
|
+
return this.sent.flatMap((message) => ('method' in message ? [message.method] : []));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function flush(): Promise<void> {
|
|
69
|
+
for (let i = 0; i < 15; i++) {
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const origin = { line: 0, character: 0 };
|
|
75
|
+
|
|
76
|
+
export async function sessionWith(capabilities: ServerCapabilities = {}) {
|
|
77
|
+
const transport = new ScriptedTransport();
|
|
78
|
+
transport.autoInitialize = { textDocumentSync: 2, ...capabilities };
|
|
79
|
+
const session = new LspSession(transport);
|
|
80
|
+
await session.initialize();
|
|
81
|
+
return { transport, session };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Bun runs the event loop inside `expect(promise).rejects` of a promise that has not settled, so a rejection that comes later is checked here. */
|
|
85
|
+
export function rejecting(promise: Promise<unknown>, check: (error: unknown) => void): Promise<void> {
|
|
86
|
+
return promise.then(
|
|
87
|
+
() => {
|
|
88
|
+
throw new Error('Expected the promise to reject');
|
|
89
|
+
},
|
|
90
|
+
(error) => check(error)
|
|
91
|
+
);
|
|
92
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { JsonRpcConnection, type RequestHandler } from './connection.ts';
|
|
2
|
+
import { applyContentChanges } from './edits.ts';
|
|
3
|
+
import type { ContentChange, Diagnostic, InitializeResult, LspTransport, ServerCapabilities } from './protocol.ts';
|
|
4
|
+
|
|
5
|
+
export { createMemoryTransportPair } from './transport.ts';
|
|
6
|
+
|
|
7
|
+
export interface FakeLanguageServerOptions {
|
|
8
|
+
capabilities?: ServerCapabilities;
|
|
9
|
+
serverInfo?: InitializeResult['serverInfo'];
|
|
10
|
+
handlers?: Record<string, RequestHandler>;
|
|
11
|
+
/* Never answers the handshake, like a server that hangs while it starts. */
|
|
12
|
+
silent?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FakeDocument {
|
|
16
|
+
languageId: string;
|
|
17
|
+
text: string;
|
|
18
|
+
version: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReceivedMessage {
|
|
22
|
+
method: string;
|
|
23
|
+
params: unknown;
|
|
24
|
+
/* Present for a request, absent for a notification. */
|
|
25
|
+
id?: number | string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* A language server for tests, on the far end of a transport. It answers the handshake, keeps the
|
|
30
|
+
* text of every document it was told about the way a real server does, and answers whatever a test
|
|
31
|
+
* registers with `handle`; any other request is refused as unsupported.
|
|
32
|
+
*/
|
|
33
|
+
export class FakeLanguageServer {
|
|
34
|
+
readonly connection: JsonRpcConnection;
|
|
35
|
+
readonly received: ReceivedMessage[] = [];
|
|
36
|
+
readonly documents = new Map<string, FakeDocument>();
|
|
37
|
+
shutdownRequested = false;
|
|
38
|
+
exited = false;
|
|
39
|
+
|
|
40
|
+
constructor(transport: LspTransport, options: FakeLanguageServerOptions = {}) {
|
|
41
|
+
this.connection = new JsonRpcConnection(transport, 0);
|
|
42
|
+
transport.onMessage((message) => {
|
|
43
|
+
const { method, params, id } = message as ReceivedMessage;
|
|
44
|
+
if (typeof method === 'string') {
|
|
45
|
+
this.received.push({ method, params, id });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
this.connection.onRequest('initialize', () =>
|
|
49
|
+
options.silent
|
|
50
|
+
? new Promise<never>(() => undefined)
|
|
51
|
+
: { capabilities: options.capabilities ?? { textDocumentSync: 2 }, serverInfo: options.serverInfo }
|
|
52
|
+
);
|
|
53
|
+
this.connection.onRequest('shutdown', () => {
|
|
54
|
+
this.shutdownRequested = true;
|
|
55
|
+
return null;
|
|
56
|
+
});
|
|
57
|
+
this.connection.onNotification('exit', () => {
|
|
58
|
+
this.exited = true;
|
|
59
|
+
});
|
|
60
|
+
this.connection.onNotification('textDocument/didOpen', (params) => {
|
|
61
|
+
const { textDocument } = params as { textDocument: { uri: string; languageId: string; text: string; version: number } };
|
|
62
|
+
this.documents.set(textDocument.uri, { languageId: textDocument.languageId, text: textDocument.text, version: textDocument.version });
|
|
63
|
+
});
|
|
64
|
+
this.connection.onNotification('textDocument/didChange', (params) => {
|
|
65
|
+
const { textDocument, contentChanges } = params as { textDocument: { uri: string; version: number }; contentChanges: ContentChange[] };
|
|
66
|
+
const document = this.documents.get(textDocument.uri);
|
|
67
|
+
if (document) {
|
|
68
|
+
document.text = applyContentChanges(document.text, contentChanges);
|
|
69
|
+
document.version = textDocument.version;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
this.connection.onNotification('textDocument/didClose', (params) => {
|
|
73
|
+
this.documents.delete((params as { textDocument: { uri: string } }).textDocument.uri);
|
|
74
|
+
});
|
|
75
|
+
for (const [method, handler] of Object.entries(options.handlers ?? {})) {
|
|
76
|
+
this.connection.onRequest(method, handler);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
handle(method: string, handler: RequestHandler): void {
|
|
81
|
+
this.connection.onRequest(method, handler);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* The params of every request or notification of `method`, in the order they came. */
|
|
85
|
+
paramsOf(method: string): unknown[] {
|
|
86
|
+
return this.received.filter((message) => message.method === method).map((message) => message.params);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
publishDiagnostics(uri: string, diagnostics: Diagnostic[], version?: number): Promise<void> {
|
|
90
|
+
return this.connection.notify('textDocument/publishDiagnostics', { uri, version, diagnostics });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
notify(method: string, params?: unknown): Promise<void> {
|
|
94
|
+
return this.connection.notify(method, params);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
|
98
|
+
return this.connection.request<T>(method, params);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* The process went away: the transport closes under the client. */
|
|
102
|
+
crash(): Promise<void> {
|
|
103
|
+
return this.connection.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { Disposable, LspTransport, RpcMessage } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_MESSAGE_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
const MAX_HEADER_BYTES = 8192;
|
|
5
|
+
|
|
6
|
+
function asError(error: unknown): Error {
|
|
7
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The listeners of a transport. A message that arrives before anyone listens is held and handed to
|
|
12
|
+
* the first listener, since a server may speak the moment its pipe opens.
|
|
13
|
+
*/
|
|
14
|
+
class TransportEvents {
|
|
15
|
+
closed = false;
|
|
16
|
+
private readonly messages = new Set<(message: unknown) => void>();
|
|
17
|
+
private readonly closes = new Set<(error?: Error) => void>();
|
|
18
|
+
private backlog: unknown[] = [];
|
|
19
|
+
private error?: Error;
|
|
20
|
+
|
|
21
|
+
onMessage = (listener: (message: unknown) => void): Disposable => {
|
|
22
|
+
this.messages.add(listener);
|
|
23
|
+
for (const message of this.backlog.splice(0)) {
|
|
24
|
+
listener(message);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
dispose: () => {
|
|
28
|
+
this.messages.delete(listener);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
onClose = (listener: (error?: Error) => void): Disposable => {
|
|
34
|
+
this.closes.add(listener);
|
|
35
|
+
if (this.closed) {
|
|
36
|
+
listener(this.error);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
dispose: () => {
|
|
40
|
+
this.closes.delete(listener);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
receive(message: unknown): void {
|
|
46
|
+
if (this.closed) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (this.messages.size === 0) {
|
|
50
|
+
this.backlog.push(message);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const listener of this.messages) {
|
|
54
|
+
listener(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
finish(error?: Error): void {
|
|
59
|
+
if (this.closed) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.closed = true;
|
|
63
|
+
this.error = error;
|
|
64
|
+
this.backlog = [];
|
|
65
|
+
for (const listener of this.closes) {
|
|
66
|
+
listener(error);
|
|
67
|
+
}
|
|
68
|
+
this.closes.clear();
|
|
69
|
+
this.messages.clear();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function concat(chunks: readonly Uint8Array[], size: number): Uint8Array {
|
|
74
|
+
if (chunks.length === 1) {
|
|
75
|
+
return chunks[0];
|
|
76
|
+
}
|
|
77
|
+
const joined = new Uint8Array(size);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
for (const chunk of chunks) {
|
|
80
|
+
joined.set(chunk, offset);
|
|
81
|
+
offset += chunk.length;
|
|
82
|
+
}
|
|
83
|
+
return joined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function indexOfHeaderEnd(bytes: Uint8Array): number {
|
|
87
|
+
for (let i = 0; i + 3 < bytes.length; i++) {
|
|
88
|
+
if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10) {
|
|
89
|
+
return i;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return -1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Reads `Content-Length` framed messages, the way a language server speaks over stdio. */
|
|
96
|
+
export class ContentLengthDecoder {
|
|
97
|
+
private readonly maxMessageBytes: number;
|
|
98
|
+
private chunks: Uint8Array[] = [];
|
|
99
|
+
private size = 0;
|
|
100
|
+
private length: number | null = null;
|
|
101
|
+
|
|
102
|
+
constructor(maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES) {
|
|
103
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Every complete message in what has arrived so far. Throws on a frame it cannot read, after which the stream is no use. */
|
|
107
|
+
accept(chunk: Uint8Array): unknown[] {
|
|
108
|
+
this.chunks.push(chunk);
|
|
109
|
+
this.size += chunk.length;
|
|
110
|
+
const messages: unknown[] = [];
|
|
111
|
+
while (true) {
|
|
112
|
+
if (this.length === null) {
|
|
113
|
+
const buffer = concat(this.chunks, this.size);
|
|
114
|
+
this.chunks = [buffer];
|
|
115
|
+
const boundary = indexOfHeaderEnd(buffer);
|
|
116
|
+
if (boundary < 0) {
|
|
117
|
+
if (buffer.length > MAX_HEADER_BYTES) {
|
|
118
|
+
throw new Error('LSP header is too large');
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
const header = new TextDecoder().decode(buffer.subarray(0, boundary));
|
|
123
|
+
const match = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
|
|
124
|
+
if (!match || boundary > MAX_HEADER_BYTES) {
|
|
125
|
+
throw new Error('Invalid LSP Content-Length header');
|
|
126
|
+
}
|
|
127
|
+
const length = Number(match[1]);
|
|
128
|
+
if (!length || length > this.maxMessageBytes) {
|
|
129
|
+
throw new Error('LSP message is too large');
|
|
130
|
+
}
|
|
131
|
+
this.length = length;
|
|
132
|
+
this.chunks = [buffer.subarray(boundary + 4)];
|
|
133
|
+
this.size = buffer.length - boundary - 4;
|
|
134
|
+
}
|
|
135
|
+
if (this.size < this.length) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
const buffer = concat(this.chunks, this.size);
|
|
139
|
+
messages.push(JSON.parse(new TextDecoder().decode(buffer.subarray(0, this.length))));
|
|
140
|
+
this.chunks = [buffer.subarray(this.length)];
|
|
141
|
+
this.size = buffer.length - this.length;
|
|
142
|
+
this.length = null;
|
|
143
|
+
}
|
|
144
|
+
return messages;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function encodeMessage(message: RpcMessage): Uint8Array {
|
|
149
|
+
const body = new TextEncoder().encode(JSON.stringify(message));
|
|
150
|
+
const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`);
|
|
151
|
+
const framed = new Uint8Array(header.length + body.length);
|
|
152
|
+
framed.set(header, 0);
|
|
153
|
+
framed.set(body, header.length);
|
|
154
|
+
return framed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* What a stdio transport needs of a process: its stdin and stdout as bytes, which the host adapts a child to. */
|
|
158
|
+
export interface ByteStream {
|
|
159
|
+
write(chunk: Uint8Array): void | Promise<void>;
|
|
160
|
+
onData(listener: (chunk: Uint8Array) => void): Disposable;
|
|
161
|
+
/* Called once, when the stream ended; an error when it ended badly. */
|
|
162
|
+
onClose(listener: (error?: Error) => void): Disposable;
|
|
163
|
+
close(): void | Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createStreamTransport(stream: ByteStream, options: { maxMessageBytes?: number } = {}): LspTransport {
|
|
167
|
+
const events = new TransportEvents();
|
|
168
|
+
const decoder = new ContentLengthDecoder(options.maxMessageBytes);
|
|
169
|
+
stream.onData((chunk) => {
|
|
170
|
+
try {
|
|
171
|
+
for (const message of decoder.accept(chunk)) {
|
|
172
|
+
events.receive(message);
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
events.finish(asError(error));
|
|
176
|
+
void Promise.resolve(stream.close()).catch(() => undefined);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
stream.onClose((error) => events.finish(error));
|
|
180
|
+
return {
|
|
181
|
+
onMessage: events.onMessage,
|
|
182
|
+
onClose: events.onClose,
|
|
183
|
+
async send(message) {
|
|
184
|
+
if (events.closed) {
|
|
185
|
+
throw new Error('LSP stream is closed');
|
|
186
|
+
}
|
|
187
|
+
await stream.write(encodeMessage(message));
|
|
188
|
+
},
|
|
189
|
+
async close() {
|
|
190
|
+
events.finish();
|
|
191
|
+
await stream.close();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Two ends of one connection in memory: what one sends the other receives, as JSON and one tick later. Closing either ends both. */
|
|
197
|
+
export function createMemoryTransportPair(): [LspTransport, LspTransport] {
|
|
198
|
+
const left = new TransportEvents();
|
|
199
|
+
const right = new TransportEvents();
|
|
200
|
+
const end = (inbox: TransportEvents, outbox: TransportEvents): LspTransport => ({
|
|
201
|
+
onMessage: inbox.onMessage,
|
|
202
|
+
onClose: inbox.onClose,
|
|
203
|
+
send(message) {
|
|
204
|
+
if (inbox.closed || outbox.closed) {
|
|
205
|
+
throw new Error('LSP transport is closed');
|
|
206
|
+
}
|
|
207
|
+
const copy: unknown = JSON.parse(JSON.stringify(message));
|
|
208
|
+
queueMicrotask(() => outbox.receive(copy));
|
|
209
|
+
},
|
|
210
|
+
close() {
|
|
211
|
+
inbox.finish();
|
|
212
|
+
outbox.finish();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
return [end(left, right), end(right, left)];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface WebSocketTransportOptions {
|
|
219
|
+
protocols?: string[];
|
|
220
|
+
signal?: AbortSignal;
|
|
221
|
+
timeoutMs?: number;
|
|
222
|
+
createWebSocket?: (url: string, protocols?: string[]) => WebSocket;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const MAX_SOCKET_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
226
|
+
|
|
227
|
+
/* JSON text frames over a WebSocket, which a browser can open and a daemon can serve. */
|
|
228
|
+
export async function connectWebSocket(url: string, options: WebSocketTransportOptions = {}): Promise<LspTransport> {
|
|
229
|
+
if (options.signal?.aborted) {
|
|
230
|
+
throw new Error('WebSocket connection cancelled');
|
|
231
|
+
}
|
|
232
|
+
const socket = options.createWebSocket ? options.createWebSocket(url, options.protocols) : new WebSocket(url, options.protocols);
|
|
233
|
+
const events = new TransportEvents();
|
|
234
|
+
socket.addEventListener('message', (event) => {
|
|
235
|
+
try {
|
|
236
|
+
if (typeof event.data !== 'string') {
|
|
237
|
+
throw new Error('LSP WebSocket expects JSON text frames');
|
|
238
|
+
}
|
|
239
|
+
events.receive(JSON.parse(event.data));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
events.finish(asError(error));
|
|
242
|
+
socket.close(1003, 'Invalid JSON message');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
socket.addEventListener('close', (event) => events.finish(event.code === 1000 ? undefined : new Error(`LSP WebSocket closed (${event.code})`)));
|
|
246
|
+
socket.addEventListener('error', () => events.finish(new Error('LSP WebSocket failed')));
|
|
247
|
+
await new Promise<void>((resolve, reject) => {
|
|
248
|
+
const timer = setTimeout(() => fail(new Error('LSP WebSocket connection timed out')), options.timeoutMs ?? 10_000);
|
|
249
|
+
const cleanup = (): void => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
socket.removeEventListener('open', open);
|
|
252
|
+
socket.removeEventListener('error', failed);
|
|
253
|
+
socket.removeEventListener('close', closed);
|
|
254
|
+
options.signal?.removeEventListener('abort', abort);
|
|
255
|
+
};
|
|
256
|
+
const fail = (reason: Error): void => {
|
|
257
|
+
cleanup();
|
|
258
|
+
socket.close();
|
|
259
|
+
reject(reason);
|
|
260
|
+
};
|
|
261
|
+
const open = (): void => {
|
|
262
|
+
cleanup();
|
|
263
|
+
resolve();
|
|
264
|
+
};
|
|
265
|
+
const failed = (): void => fail(new Error('LSP WebSocket connection failed'));
|
|
266
|
+
const closed = (): void => fail(new Error('LSP WebSocket closed before opening'));
|
|
267
|
+
const abort = (): void => fail(new Error('LSP WebSocket connection cancelled'));
|
|
268
|
+
socket.addEventListener('open', open, { once: true });
|
|
269
|
+
socket.addEventListener('error', failed, { once: true });
|
|
270
|
+
socket.addEventListener('close', closed, { once: true });
|
|
271
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
272
|
+
});
|
|
273
|
+
return {
|
|
274
|
+
onMessage: events.onMessage,
|
|
275
|
+
onClose: events.onClose,
|
|
276
|
+
send(message) {
|
|
277
|
+
if (events.closed || socket.readyState !== 1) {
|
|
278
|
+
throw new Error('LSP WebSocket is closed');
|
|
279
|
+
}
|
|
280
|
+
if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) {
|
|
281
|
+
throw new Error('LSP WebSocket send buffer is full');
|
|
282
|
+
}
|
|
283
|
+
socket.send(JSON.stringify(message));
|
|
284
|
+
},
|
|
285
|
+
close() {
|
|
286
|
+
events.finish();
|
|
287
|
+
socket.close(1000);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
package/src/uris.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function isWindowsDrive(segment: string): boolean {
|
|
2
|
+
return /^[A-Za-z]:$/.test(segment);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* An absolute path, POSIX or with a Windows drive, as the `file:` URI a language server names it by. */
|
|
6
|
+
export function pathToFileUri(path: string): string {
|
|
7
|
+
const normalized = path.replaceAll('\\', '/');
|
|
8
|
+
const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`;
|
|
9
|
+
const segments = rooted.split('/').map((segment, index) => (index === 1 && isWindowsDrive(segment) ? segment : encodeURIComponent(segment)));
|
|
10
|
+
return `file://${segments.join('/')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* The path behind a `file:` URI, in the form of the machine it names, or null for any other kind of URI. */
|
|
14
|
+
export function fileUriToPath(uri: string): string | null {
|
|
15
|
+
let url: URL;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(uri);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol !== 'file:') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const path = decodeURIComponent(url.pathname);
|
|
25
|
+
if (/^\/[A-Za-z]:/.test(path)) {
|
|
26
|
+
return path.slice(1).replaceAll('/', '\\');
|
|
27
|
+
}
|
|
28
|
+
return path;
|
|
29
|
+
}
|
package/src/vue.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { offsetAt } from './edits.ts';
|
|
2
|
+
import type { Disposable } from './protocol.ts';
|
|
3
|
+
import type { LspSession } from './session.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Vue's server leaves TypeScript to a tsserver of its own project and asks for it with
|
|
7
|
+
* `tsserver/request` notifications, which this relays to the TypeScript server's
|
|
8
|
+
* `typescript.tsserverRequest` command. Attach it before the Vue session initializes, and open each
|
|
9
|
+
* `.vue` document in both sessions, TypeScript first.
|
|
10
|
+
*/
|
|
11
|
+
export function bridgeVueTypeScript(vue: LspSession, typescript: LspSession): Disposable {
|
|
12
|
+
let disposed = false;
|
|
13
|
+
const subscription = vue.onNotification('tsserver/request', async (params) => {
|
|
14
|
+
// vscode-jsonrpc encodes this single positional argument as an outer array.
|
|
15
|
+
const positional = Array.isArray(params) && params.length === 1 && Array.isArray(params[0]);
|
|
16
|
+
const tuple = positional ? params[0] : params;
|
|
17
|
+
if (!Array.isArray(tuple) || tuple.length !== 3) {
|
|
18
|
+
throw new Error('Invalid Vue tsserver/request notification');
|
|
19
|
+
}
|
|
20
|
+
const [id, command, args] = tuple as [number, string, unknown];
|
|
21
|
+
let body: unknown = null;
|
|
22
|
+
try {
|
|
23
|
+
const response = await typescript.executeCommand<{ body?: unknown }>('typescript.tsserverRequest', [
|
|
24
|
+
command,
|
|
25
|
+
args,
|
|
26
|
+
{ isAsync: false, lowPriority: true }
|
|
27
|
+
]);
|
|
28
|
+
body = response?.body ?? null;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
vue.connection.reportError(error);
|
|
31
|
+
}
|
|
32
|
+
// Vue keeps the request pending until it gets an answer, a null one included.
|
|
33
|
+
if (!disposed && !vue.connection.isClosed) {
|
|
34
|
+
await vue.connection.notify('tsserver/response', positional ? [[id, body]] : [id, body]);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
dispose() {
|
|
39
|
+
disposed = true;
|
|
40
|
+
subscription.dispose();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type VueServer = 'vue' | 'typescript';
|
|
46
|
+
|
|
47
|
+
/* A `<script>` body, or the expression of an interpolation or a `v-` / `:` / `@` attribute: the places TypeScript knows more than Vue does. */
|
|
48
|
+
export function isVueExpression(text: string, offset: number): boolean {
|
|
49
|
+
const script = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
|
|
50
|
+
for (let match = script.exec(text); match; match = script.exec(text)) {
|
|
51
|
+
if (offset > match.index + match[0].indexOf('>') && offset < match.index + match[0].lastIndexOf('</')) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const expressions = /\{\{[\s\S]*?\}\}|(?:[:@][\w.:-]+|v-[\w.:-]+)\s*=\s*(?:"[^"]*"|'[^']*')/g;
|
|
56
|
+
for (let match = expressions.exec(text); match; match = expressions.exec(text)) {
|
|
57
|
+
if (offset >= match.index && offset <= match.index + match[0].length) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function paramsOffset(text: string, params: unknown): number | null {
|
|
65
|
+
if (!params || typeof params !== 'object') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const position =
|
|
69
|
+
'position' in params
|
|
70
|
+
? params.position
|
|
71
|
+
: 'range' in params && params.range && typeof params.range === 'object' && 'start' in params.range
|
|
72
|
+
? params.range.start
|
|
73
|
+
: null;
|
|
74
|
+
if (!position || typeof position !== 'object') {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return offsetAt(text, position as { line: number; character: number });
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
* Which of the two servers of a Vue document to ask first, the other being the fallback when the
|
|
86
|
+
* first does not support the method. Vue answers template markup, HTML and CSS; TypeScript answers
|
|
87
|
+
* a script and the expressions of a template, and the inlay hints of all of it.
|
|
88
|
+
*/
|
|
89
|
+
export function vueServerOrder(method: string, text: string, params: unknown): [VueServer, VueServer] {
|
|
90
|
+
const offset = paramsOffset(text, params);
|
|
91
|
+
const typescriptFirst = method === 'textDocument/inlayHint' || (offset !== null && isVueExpression(text, offset));
|
|
92
|
+
return typescriptFirst ? ['typescript', 'vue'] : ['vue', 'typescript'];
|
|
93
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { globMatch } from './glob.ts';
|
|
2
|
+
import type { FileSystemWatcher } from './protocol.ts';
|
|
3
|
+
import { fileUriToPath } from './uris.ts';
|
|
4
|
+
|
|
5
|
+
/* A change of a file as the protocol numbers it: created, changed, deleted. */
|
|
6
|
+
export type FileChangeType = 1 | 2 | 3;
|
|
7
|
+
|
|
8
|
+
/* Folders whose files a server hears about only when a pattern of its own names the folder. */
|
|
9
|
+
const SPARING_FOLDERS = ['node_modules', 'vendor', '.git'];
|
|
10
|
+
|
|
11
|
+
function relativeTo(root: string, path: string): string | null {
|
|
12
|
+
const prefix = root.endsWith('/') ? root : `${root}/`;
|
|
13
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* A pattern with the folder it is relative to, or null for a relative pattern whose base is no folder of this machine. `base` is undefined for a string pattern. */
|
|
17
|
+
function patternOf(watcher: FileSystemWatcher): { base: string | undefined; pattern: string } | null {
|
|
18
|
+
const { globPattern } = watcher;
|
|
19
|
+
if (typeof globPattern === 'string') {
|
|
20
|
+
return { base: undefined, pattern: globPattern };
|
|
21
|
+
}
|
|
22
|
+
const base = fileUriToPath(typeof globPattern.baseUri === 'string' ? globPattern.baseUri : globPattern.baseUri.uri);
|
|
23
|
+
return base === null ? null : { base, pattern: globPattern.pattern };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* Whether a server asked to hear about this change. A string pattern is relative to the project folder
|
|
28
|
+
* (or absolute), a relative pattern to its own base. A path inside `node_modules`, `vendor` or `.git` only
|
|
29
|
+
* matches a pattern that names that folder, since `**` over a dependency tree is what a server never means.
|
|
30
|
+
*/
|
|
31
|
+
export function watchesFile(watchers: readonly FileSystemWatcher[], root: string, path: string, type: FileChangeType): boolean {
|
|
32
|
+
const inProject = relativeTo(root, path);
|
|
33
|
+
const segments = (inProject ?? path).split('/');
|
|
34
|
+
return watchers.some((watcher) => {
|
|
35
|
+
if (((watcher.kind ?? 7) & (1 << (type - 1))) === 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const parts = patternOf(watcher);
|
|
39
|
+
if (parts === null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const { base, pattern } = parts;
|
|
43
|
+
const subject = pattern.startsWith('/') ? path : base === undefined ? inProject : relativeTo(base, path);
|
|
44
|
+
if (subject === null || !globMatch(subject, pattern)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return SPARING_FOLDERS.every((folder) => !segments.includes(folder) || pattern.includes(folder));
|
|
48
|
+
});
|
|
49
|
+
}
|