@vemjs/lsp-client 0.1.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/CHANGELOG.md +67 -0
- package/README.md +132 -0
- package/dist/JsonRpcClient.d.ts +48 -0
- package/dist/JsonRpcClient.d.ts.map +1 -0
- package/dist/JsonRpcClient.js +123 -0
- package/dist/JsonRpcClient.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/package.json +17 -0
- package/src/JsonRpcClient.ts +164 -0
- package/src/index.test.ts +211 -0
- package/src/index.ts +270 -0
- package/tsconfig.json +13 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @vemjs/lsp-client
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3fa4848: feat(lsp): implement JSON-RPC 2.0 client, document sync, diagnostics & completion
|
|
8
|
+
|
|
9
|
+
- Add `JsonRpcClient` — JSON-RPC 2.0 engine over WebSocket with pending-Map request/response correlation and notification dispatch
|
|
10
|
+
- Add `LSPClient` — full LSP lifecycle (initialize handshake, textDocument/didOpen, didChange, didClose, completion, hover, publishDiagnostics bridging)
|
|
11
|
+
- Add `Diagnostic` / `DiagnosticSeverity` types to `@vemjs/core`
|
|
12
|
+
- Add `VemEditorState.setDiagnostics()`, `getDiagnostics()`, `onPublishDiagnostics()` API
|
|
13
|
+
- Add `VemEditorState.getText()` public buffer content accessor
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 0498765: chore: release infrastructure, package metadata, and documentation scaffolding
|
|
18
|
+
|
|
19
|
+
This changeset covers all release preparation work for the initial 0.1.0 publish:
|
|
20
|
+
|
|
21
|
+
**Package metadata** — Added `license`, `repository`, `keywords`, and `publishConfig` fields to
|
|
22
|
+
all four packages so they display correctly on npmjs.com with proper source links, license badges,
|
|
23
|
+
and searchable tags.
|
|
24
|
+
|
|
25
|
+
**CI/CD pipeline** — Rewrote `.github/workflows/ci.yml` and `release.yml`:
|
|
26
|
+
|
|
27
|
+
- `quality` job: build → test → lint (oxlint) → dead-code scan (knip) on every PR and push
|
|
28
|
+
- `publish` job: automatic `changeset publish` to npm on every merge to `main` via
|
|
29
|
+
`changesets/action@v1` using the `NPM_TOKEN` org secret
|
|
30
|
+
|
|
31
|
+
**Changesets** — Initialized `.changeset/` with a `config.json` configured for public access and
|
|
32
|
+
patch-level internal dependency updates, enabling a fully automated release flow.
|
|
33
|
+
|
|
34
|
+
**Tooling** — Added `knip.config.ts` (dead-code detection), `oxlintrc.json` (TypeScript-aware
|
|
35
|
+
lint rules), `.lintstagedrc.json` (auto-fix staged files on commit), and updated root
|
|
36
|
+
`package.json` scripts: `build`, `test`, `lint`, `knip`, `changeset`, `version-packages`,
|
|
37
|
+
`release`.
|
|
38
|
+
|
|
39
|
+
**Dependabot** — Configured weekly npm dependency scanning with dev/prod groups and
|
|
40
|
+
`@vectojs/*` major-version pin to avoid upstream breaking changes.
|
|
41
|
+
|
|
42
|
+
**Repository** — Updated root `README.md` with CI/npm/license badges and package table.
|
|
43
|
+
Updated `SECURITY.md` with all four `@vemjs/*` packages and coordinated-disclosure guidance.
|
|
44
|
+
Added GitHub topics (vim, editor, typescript, vectojs, canvas, modal-editing, lsp) and branch
|
|
45
|
+
protection requiring the `quality` status check before merging to `main`.
|
|
46
|
+
|
|
47
|
+
**Build hygiene** — Cleaned all `dist/` directories and rebuilt from source to ensure no
|
|
48
|
+
test artefacts are included in published tarballs. Verified `knip` reports zero issues.
|
|
49
|
+
|
|
50
|
+
- Updated dependencies [0498765]
|
|
51
|
+
- Updated dependencies [3fa4848]
|
|
52
|
+
- @vemjs/core@0.2.0
|
|
53
|
+
|
|
54
|
+
## 0.2.0 (unreleased)
|
|
55
|
+
|
|
56
|
+
### Minor Changes
|
|
57
|
+
|
|
58
|
+
- Add `JsonRpcClient` — JSON-RPC 2.0 engine over WebSocket with pending-Map correlation and notification dispatch
|
|
59
|
+
- Add `LSPClient` — full LSP lifecycle: initialize handshake, textDocument/didOpen, didChange, didClose, completion, hover, publishDiagnostics
|
|
60
|
+
- Wire `VemEditorState` events to automatic document sync
|
|
61
|
+
- Bridge `publishDiagnostics` notifications to `VemEditorState.setDiagnostics()`
|
|
62
|
+
|
|
63
|
+
## 0.1.0
|
|
64
|
+
|
|
65
|
+
### Features
|
|
66
|
+
|
|
67
|
+
- Initial `LSPClient` stub with WebSocket connect and completion request placeholders
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @vemjs/lsp-client
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@vemjs/lsp-client)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
A lightweight Language Server Protocol (LSP) client and JSON-RPC 2.0 communication engine for the **Vem Editor**. It establishes connection to language servers over WebSocket connections, synchronizes document buffers dynamically, and maps incoming diagnostics or completion results straight to the editor state.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **JSON-RPC 2.0 Protocol Engine**: Direct implementation of the JSON-RPC 2.0 spec over WebSockets, including automatic correlation of request/response IDs and fire-and-forget notifications.
|
|
11
|
+
- **Document Synchronization**: Binds directly to `@vemjs/core` editor buffers, automatically sending `textDocument/didOpen`, `textDocument/didChange`, and `textDocument/didClose` notification events on text edit triggers.
|
|
12
|
+
- **AutoComplete Bridge**: Fetch completions asynchronously on demand and dispatch them to listeners.
|
|
13
|
+
- **Hover Capability**: Query type signatures or docs from language servers under current editor positions.
|
|
14
|
+
- **Diagnostics Translation**: Subscribes to `textDocument/publishDiagnostics` notifications, translates severity mappings, and automatically updates `@vemjs/core` diagnostics storage.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @vemjs/lsp-client
|
|
20
|
+
# or via npm
|
|
21
|
+
npm install @vemjs/lsp-client
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
Initialize the LSP client, attach it to the editor state, and connect:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { VemEditorState } from '@vemjs/core';
|
|
30
|
+
import { LSPClient } from '@vemjs/lsp-client';
|
|
31
|
+
|
|
32
|
+
const editorState = new VemEditorState('const a = 123;');
|
|
33
|
+
const lsp = new LSPClient('ws://localhost:8080', 'file:///workspace/app.ts', 'typescript');
|
|
34
|
+
|
|
35
|
+
// Attach before connecting to auto-bind didOpen / didChange buffer events
|
|
36
|
+
await lsp.connect(editorState);
|
|
37
|
+
|
|
38
|
+
// Request completions at line 0, character 12
|
|
39
|
+
const completions = await lsp.requestCompletion(0, 12);
|
|
40
|
+
console.log('Completions:', completions);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## API Reference
|
|
44
|
+
|
|
45
|
+
### `JsonRpcClient`
|
|
46
|
+
|
|
47
|
+
Low-level client for communication.
|
|
48
|
+
|
|
49
|
+
- `constructor(url: string)`: Creates a new JSON-RPC client.
|
|
50
|
+
- `connect(): Promise<void>`: Connects to the WebSocket URL.
|
|
51
|
+
- `disconnect(): void`: Closes the WebSocket connection.
|
|
52
|
+
- `isConnected: boolean`: Get connection status.
|
|
53
|
+
- `sendRequest(method: string, params?: unknown): Promise<unknown>`: Sends a request with generated unique ID and awaits the server's reply.
|
|
54
|
+
- `sendNotification(method: string, params?: unknown): void`: Sends a notification without expecting a response.
|
|
55
|
+
- `onNotification(method: string, cb: (params: unknown) => void): void`: Registers a handler for server notifications.
|
|
56
|
+
|
|
57
|
+
### `LSPClient`
|
|
58
|
+
|
|
59
|
+
High-level LSP integration wrapper.
|
|
60
|
+
|
|
61
|
+
- `constructor(serverUrl: string, fileUri: string, languageId: string)`: Creates an LSPClient instance.
|
|
62
|
+
- `connect(editorState?: VemEditorState): Promise<void>`: Initiates WebSocket connection, attaches optional editor state, and performs the standard LSP `initialize` / `initialized` handshake.
|
|
63
|
+
- `disconnect(): void`: Sends LSP `exit` notification and disconnects.
|
|
64
|
+
- `attach(editorState: VemEditorState): void`: Binds to editor buffer change events to automate buffer sync.
|
|
65
|
+
- `requestCompletion(line: number, character: number): Promise<LspCompletionItem[]>`: Queries the server for autocomplete options at the specified coordinates.
|
|
66
|
+
- `requestHover(line: number, character: number): Promise<string | null>`: Queries type details or docs at the position.
|
|
67
|
+
- `sendDidClose(): void`: Sends standard document close notification to free server resources.
|
|
68
|
+
- `onCompletion(cb: (items: LspCompletionItem[]) => void): void`: Register autocomplete event listener.
|
|
69
|
+
- `onHover(cb: (content: string) => void): void`: Register hover event listener.
|
|
70
|
+
- `setFileUri(uri: string): void`: Switches file URI context.
|
|
71
|
+
- `setLanguageId(id: string): void`: Switches language context.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Connecting to TypeScript Language Server
|
|
76
|
+
|
|
77
|
+
To run a language server in the browser environment, you can proxy `typescript-language-server` or `typescript-language-server --stdio` through a simple WebSocket proxy (such as `ws-jsonrpc-proxy` or standard stdio-to-ws bridges).
|
|
78
|
+
|
|
79
|
+
Here is a complete setup workflow:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { VemEditorState } from '@vemjs/core';
|
|
83
|
+
import { LSPClient } from '@vemjs/lsp-client';
|
|
84
|
+
|
|
85
|
+
const editor = new VemEditorState('console.l');
|
|
86
|
+
|
|
87
|
+
// Establish LSPClient connection to the proxy URL
|
|
88
|
+
const lsp = new LSPClient('ws://localhost:2087', 'file:///mnt/workspace/index.ts', 'typescript');
|
|
89
|
+
|
|
90
|
+
// Register autocomplete handler
|
|
91
|
+
lsp.onCompletion((items) => {
|
|
92
|
+
console.log('Received completions:');
|
|
93
|
+
items.forEach((item) => {
|
|
94
|
+
console.log(`- ${item.label} (${item.detail ?? 'no detail'})`);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await lsp.connect(editor);
|
|
99
|
+
|
|
100
|
+
// Send keys to append characters to trigger didChange
|
|
101
|
+
editor.input('o'); // buffer now contains: console.lo
|
|
102
|
+
editor.input('g'); // buffer now contains: console.log
|
|
103
|
+
|
|
104
|
+
// Ask for completion at line 0, position 11 (after 'log')
|
|
105
|
+
const results = await lsp.requestCompletion(0, 11);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Architecture
|
|
109
|
+
|
|
110
|
+
```mermaid
|
|
111
|
+
sequenceDiagram
|
|
112
|
+
participant Editor as VemEditorState
|
|
113
|
+
participant LSP as LSPClient
|
|
114
|
+
participant RPC as JsonRpcClient
|
|
115
|
+
participant LS as Language Server (via WS)
|
|
116
|
+
|
|
117
|
+
Editor->>LSP: onDidOpenBuffer / onDidChangeBuffer
|
|
118
|
+
LSP->>RPC: sendNotification("textDocument/didChange", text)
|
|
119
|
+
RPC->>LS: WebSocket Message
|
|
120
|
+
LS-->>RPC: textDocument/publishDiagnostics
|
|
121
|
+
RPC-->>LSP: Trigger Diagnostic Handler
|
|
122
|
+
LSP->>Editor: setDiagnostics(mappedErrors)
|
|
123
|
+
Editor->>Editor: Trigger change callbacks (Canvas repaints)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Contributing
|
|
127
|
+
|
|
128
|
+
Please review [CONTRIBUTING.md](../../CONTRIBUTING.md) for details on our workflow and engineering guidelines.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
This package is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vemjs/lsp-client — JSON-RPC 2.0 Protocol Engine
|
|
3
|
+
*
|
|
4
|
+
* Handles request/response correlation and notification routing
|
|
5
|
+
* over a WebSocket transport layer.
|
|
6
|
+
*/
|
|
7
|
+
export interface JsonRpcRequest {
|
|
8
|
+
jsonrpc: '2.0';
|
|
9
|
+
id: number;
|
|
10
|
+
method: string;
|
|
11
|
+
params?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface JsonRpcNotification {
|
|
14
|
+
jsonrpc: '2.0';
|
|
15
|
+
method: string;
|
|
16
|
+
params?: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface JsonRpcResponse {
|
|
19
|
+
jsonrpc: '2.0';
|
|
20
|
+
id: number;
|
|
21
|
+
result?: unknown;
|
|
22
|
+
error?: {
|
|
23
|
+
code: number;
|
|
24
|
+
message: string;
|
|
25
|
+
data?: unknown;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
29
|
+
export type NotificationHandler = (params: unknown) => void;
|
|
30
|
+
export declare class JsonRpcClient {
|
|
31
|
+
private ws;
|
|
32
|
+
private nextId;
|
|
33
|
+
private pending;
|
|
34
|
+
private notificationHandlers;
|
|
35
|
+
private readyQueue;
|
|
36
|
+
private connected;
|
|
37
|
+
private readonly url;
|
|
38
|
+
constructor(url: string);
|
|
39
|
+
connect(): Promise<void>;
|
|
40
|
+
disconnect(): void;
|
|
41
|
+
get isConnected(): boolean;
|
|
42
|
+
sendRequest(method: string, params?: unknown): Promise<unknown>;
|
|
43
|
+
sendNotification(method: string, params?: unknown): void;
|
|
44
|
+
onNotification(method: string, cb: NotificationHandler): void;
|
|
45
|
+
private send;
|
|
46
|
+
private handleMessage;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=JsonRpcClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"JsonRpcClient.d.ts","sourceRoot":"","sources":["../src/JsonRpcClient.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC3D;AAED,MAAM,MAAM,cAAc,GAAG,cAAc,GAAG,mBAAmB,GAAG,eAAe,CAAC;AAEpF,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAE5D,qBAAa,aAAa;IACxB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,OAAO,CAGX;IACJ,OAAO,CAAC,oBAAoB,CAA4C;IACxE,OAAO,CAAC,UAAU,CAAsB;IACxC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;gBAEjB,GAAG,EAAE,MAAM;IAMhB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAkCxB,UAAU,IAAI,IAAI;IAMzB,IAAW,WAAW,IAAI,OAAO,CAEhC;IAIM,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAe/D,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI;IAWxD,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,mBAAmB,GAAG,IAAI;IAQpE,OAAO,CAAC,IAAI;IASZ,OAAO,CAAC,aAAa;CAyBtB"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vemjs/lsp-client — JSON-RPC 2.0 Protocol Engine
|
|
3
|
+
*
|
|
4
|
+
* Handles request/response correlation and notification routing
|
|
5
|
+
* over a WebSocket transport layer.
|
|
6
|
+
*/
|
|
7
|
+
export class JsonRpcClient {
|
|
8
|
+
ws = null;
|
|
9
|
+
nextId = 1;
|
|
10
|
+
pending = new Map();
|
|
11
|
+
notificationHandlers = new Map();
|
|
12
|
+
readyQueue = [];
|
|
13
|
+
connected = false;
|
|
14
|
+
url;
|
|
15
|
+
constructor(url) {
|
|
16
|
+
this.url = url;
|
|
17
|
+
}
|
|
18
|
+
// ── Connection ─────────────────────────────────────────────────────────────
|
|
19
|
+
connect() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
this.ws = new WebSocket(this.url);
|
|
22
|
+
this.ws.onopen = () => {
|
|
23
|
+
this.connected = true;
|
|
24
|
+
for (const fn of this.readyQueue)
|
|
25
|
+
fn();
|
|
26
|
+
this.readyQueue = [];
|
|
27
|
+
resolve();
|
|
28
|
+
};
|
|
29
|
+
this.ws.onmessage = (ev) => {
|
|
30
|
+
try {
|
|
31
|
+
this.handleMessage(JSON.parse(ev.data));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
console.warn('[JsonRpcClient] Failed to parse message:', ev.data);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
this.ws.onerror = (ev) => {
|
|
38
|
+
reject(ev);
|
|
39
|
+
};
|
|
40
|
+
this.ws.onclose = () => {
|
|
41
|
+
this.connected = false;
|
|
42
|
+
// Reject all pending requests on disconnect
|
|
43
|
+
for (const [id, p] of this.pending) {
|
|
44
|
+
p.reject(new Error(`Connection closed before response for id=${id}`));
|
|
45
|
+
}
|
|
46
|
+
this.pending.clear();
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
disconnect() {
|
|
51
|
+
this.ws?.close();
|
|
52
|
+
this.ws = null;
|
|
53
|
+
this.connected = false;
|
|
54
|
+
}
|
|
55
|
+
get isConnected() {
|
|
56
|
+
return this.connected;
|
|
57
|
+
}
|
|
58
|
+
// ── Outbound ───────────────────────────────────────────────────────────────
|
|
59
|
+
sendRequest(method, params) {
|
|
60
|
+
const id = this.nextId++;
|
|
61
|
+
const message = {
|
|
62
|
+
jsonrpc: '2.0',
|
|
63
|
+
id,
|
|
64
|
+
method,
|
|
65
|
+
...(params !== undefined && { params }),
|
|
66
|
+
};
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
this.pending.set(id, { resolve, reject });
|
|
69
|
+
this.send(message);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
sendNotification(method, params) {
|
|
73
|
+
const message = {
|
|
74
|
+
jsonrpc: '2.0',
|
|
75
|
+
method,
|
|
76
|
+
...(params !== undefined && { params }),
|
|
77
|
+
};
|
|
78
|
+
this.send(message);
|
|
79
|
+
}
|
|
80
|
+
// ── Inbound ────────────────────────────────────────────────────────────────
|
|
81
|
+
onNotification(method, cb) {
|
|
82
|
+
const handlers = this.notificationHandlers.get(method) ?? [];
|
|
83
|
+
handlers.push(cb);
|
|
84
|
+
this.notificationHandlers.set(method, handlers);
|
|
85
|
+
}
|
|
86
|
+
// ── Internal ───────────────────────────────────────────────────────────────
|
|
87
|
+
send(message) {
|
|
88
|
+
const send = () => this.ws?.send(JSON.stringify(message));
|
|
89
|
+
if (this.connected) {
|
|
90
|
+
send();
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.readyQueue.push(send);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
handleMessage(message) {
|
|
97
|
+
// Response (has `id` and `result` or `error`)
|
|
98
|
+
if ('id' in message && ('result' in message || 'error' in message)) {
|
|
99
|
+
const response = message;
|
|
100
|
+
const pending = this.pending.get(response.id);
|
|
101
|
+
if (pending) {
|
|
102
|
+
this.pending.delete(response.id);
|
|
103
|
+
if (response.error) {
|
|
104
|
+
pending.reject(response.error);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
pending.resolve(response.result);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Notification (no `id`)
|
|
113
|
+
if ('method' in message && !('id' in message)) {
|
|
114
|
+
const notification = message;
|
|
115
|
+
const handlers = this.notificationHandlers.get(notification.method);
|
|
116
|
+
if (handlers) {
|
|
117
|
+
for (const h of handlers)
|
|
118
|
+
h(notification.params);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=JsonRpcClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"JsonRpcClient.js","sourceRoot":"","sources":["../src/JsonRpcClient.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA0BH,MAAM,OAAO,aAAa;IAChB,EAAE,GAAqB,IAAI,CAAC;IAC5B,MAAM,GAAG,CAAC,CAAC;IACX,OAAO,GAAG,IAAI,GAAG,EAGtB,CAAC;IACI,oBAAoB,GAAG,IAAI,GAAG,EAAiC,CAAC;IAChE,UAAU,GAAmB,EAAE,CAAC;IAChC,SAAS,GAAG,KAAK,CAAC;IACT,GAAG,CAAS;IAE7B,YAAY,GAAW;QACrB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;IACjB,CAAC;IAED,8EAA8E;IAEvE,OAAO;QACZ,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YACrC,IAAI,CAAC,EAAE,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAElC,IAAI,CAAC,EAAE,CAAC,MAAM,GAAG,GAAG,EAAE;gBACpB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;gBACtB,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,UAAU;oBAAE,EAAE,EAAE,CAAC;gBACvC,IAAI,CAAC,UAAU,GAAG,EAAE,CAAC;gBACrB,OAAO,EAAE,CAAC;YACZ,CAAC,CAAC;YAEF,IAAI,CAAC,EAAE,CAAC,SAAS,GAAG,CAAC,EAAE,EAAE,EAAE;gBACzB,IAAI,CAAC;oBACH,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,IAAc,CAAmB,CAAC,CAAC;gBACtE,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO,CAAC,IAAI,CAAC,0CAA0C,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;gBACpE,CAAC;YACH,CAAC,CAAC;YAEF,IAAI,CAAC,EAAE,CAAC,OAAO,GAAG,CAAC,EAAE,EAAE,EAAE;gBACvB,MAAM,CAAC,EAAE,CAAC,CAAC;YACb,CAAC,CAAC;YAEF,IAAI,CAAC,EAAE,CAAC,OAAO,GAAG,GAAG,EAAE;gBACrB,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;gBACvB,4CAA4C;gBAC5C,KAAK,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;oBACnC,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,4CAA4C,EAAE,EAAE,CAAC,CAAC,CAAC;gBACxE,CAAC;gBACD,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACvB,CAAC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,UAAU;QACf,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC;QACjB,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC;QACf,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC;IACzB,CAAC;IAED,IAAW,WAAW;QACpB,OAAO,IAAI,CAAC,SAAS,CAAC;IACxB,CAAC;IAED,8EAA8E;IAEvE,WAAW,CAAC,MAAc,EAAE,MAAgB;QACjD,MAAM,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QACzB,MAAM,OAAO,GAAmB;YAC9B,OAAO,EAAE,KAAK;YACd,EAAE;YACF,MAAM;YACN,GAAG,CAAC,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,EAAE,CAAC;SACxC,CAAC;QAEF,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YACrC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;YAC1C,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACrB,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,gBAAgB,CAAC,MAAc,EAAE,MAAgB;QACtD,MAAM,OAAO,GAAwB;YACnC,OAAO,EAAE,KAAK;YACd,MAAM;YACN,GAAG,CAAC,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,EAAE,CAAC;SACxC,CAAC;QACF,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACrB,CAAC;IAED,8EAA8E;IAEvE,cAAc,CAAC,MAAc,EAAE,EAAuB;QAC3D,MAAM,QAAQ,GAAG,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAC7D,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClB,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAClD,CAAC;IAED,8EAA8E;IAEtE,IAAI,CAAC,OAAuB;QAClC,MAAM,IAAI,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;QAC1D,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACnB,IAAI,EAAE,CAAC;QACT,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC;IAEO,aAAa,CAAC,OAAuB;QAC3C,8CAA8C;QAC9C,IAAI,IAAI,IAAI,OAAO,IAAI,CAAC,QAAQ,IAAI,OAAO,IAAI,OAAO,IAAI,OAAO,CAAC,EAAE,CAAC;YACnE,MAAM,QAAQ,GAAG,OAA0B,CAAC;YAC5C,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;YAC9C,IAAI,OAAO,EAAE,CAAC;gBACZ,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;gBACjC,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;oBACnB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;gBACjC,CAAC;qBAAM,CAAC;oBACN,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;gBACnC,CAAC;YACH,CAAC;YACD,OAAO;QACT,CAAC;QAED,yBAAyB;QACzB,IAAI,QAAQ,IAAI,OAAO,IAAI,CAAC,CAAC,IAAI,IAAI,OAAO,CAAC,EAAE,CAAC;YAC9C,MAAM,YAAY,GAAG,OAA8B,CAAC;YACpD,MAAM,QAAQ,GAAG,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;YACpE,IAAI,QAAQ,EAAE,CAAC;gBACb,KAAK,MAAM,CAAC,IAAI,QAAQ;oBAAE,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;YACnD,CAAC;QACH,CAAC;IACH,CAAC;CACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vemjs/lsp-client — Language Server Protocol Client
|
|
3
|
+
*
|
|
4
|
+
* Implements the LSP specification subset needed for:
|
|
5
|
+
* - Initialize / Shutdown handshake
|
|
6
|
+
* - textDocument/didOpen, didChange, didClose
|
|
7
|
+
* - textDocument/completion (autocomplete)
|
|
8
|
+
* - textDocument/publishDiagnostics (error / warning highlights)
|
|
9
|
+
* - textDocument/hover
|
|
10
|
+
*/
|
|
11
|
+
import type { VemEditorState } from '@vemjs/core';
|
|
12
|
+
export type { JsonRpcRequest, JsonRpcNotification, JsonRpcResponse, JsonRpcMessage, NotificationHandler, } from './JsonRpcClient';
|
|
13
|
+
export interface LspPosition {
|
|
14
|
+
line: number;
|
|
15
|
+
character: number;
|
|
16
|
+
}
|
|
17
|
+
export interface LspRange {
|
|
18
|
+
start: LspPosition;
|
|
19
|
+
end: LspPosition;
|
|
20
|
+
}
|
|
21
|
+
export interface LspTextDocumentIdentifier {
|
|
22
|
+
uri: string;
|
|
23
|
+
}
|
|
24
|
+
export interface LspTextDocumentItem {
|
|
25
|
+
uri: string;
|
|
26
|
+
languageId: string;
|
|
27
|
+
version: number;
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
export interface LspCompletionItem {
|
|
31
|
+
label: string;
|
|
32
|
+
kind?: number;
|
|
33
|
+
detail?: string;
|
|
34
|
+
insertText?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface LspCompletionList {
|
|
37
|
+
isIncomplete: boolean;
|
|
38
|
+
items: LspCompletionItem[];
|
|
39
|
+
}
|
|
40
|
+
export interface LspDiagnostic {
|
|
41
|
+
range: LspRange;
|
|
42
|
+
severity?: 1 | 2 | 3 | 4;
|
|
43
|
+
message: string;
|
|
44
|
+
source?: string;
|
|
45
|
+
}
|
|
46
|
+
export type CompletionResultCallback = (items: LspCompletionItem[]) => void;
|
|
47
|
+
export type HoverCallback = (content: string) => void;
|
|
48
|
+
export declare class LSPClient {
|
|
49
|
+
private rpc;
|
|
50
|
+
private fileUri;
|
|
51
|
+
private languageId;
|
|
52
|
+
private editorState;
|
|
53
|
+
private version;
|
|
54
|
+
private initialized;
|
|
55
|
+
private completionCallbacks;
|
|
56
|
+
private hoverCallbacks;
|
|
57
|
+
constructor(serverUrl: string, fileUri: string, languageId: string);
|
|
58
|
+
connect(editorState?: VemEditorState): Promise<void>;
|
|
59
|
+
disconnect(): void;
|
|
60
|
+
attach(editorState: VemEditorState): void;
|
|
61
|
+
private sendDidOpen;
|
|
62
|
+
private sendDidChange;
|
|
63
|
+
sendDidClose(): void;
|
|
64
|
+
requestCompletion(line: number, character: number): Promise<LspCompletionItem[]>;
|
|
65
|
+
private parseCompletionResult;
|
|
66
|
+
onCompletion(cb: CompletionResultCallback): void;
|
|
67
|
+
requestHover(line: number, character: number): Promise<string | null>;
|
|
68
|
+
onHover(cb: HoverCallback): void;
|
|
69
|
+
private handlePublishDiagnostics;
|
|
70
|
+
setFileUri(uri: string): void;
|
|
71
|
+
setLanguageId(languageId: string): void;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAkC,MAAM,aAAa,CAAC;AAElF,YAAY,EACV,cAAc,EACd,mBAAmB,EACnB,eAAe,EACf,cAAc,EACd,mBAAmB,GACpB,MAAM,iBAAiB,CAAC;AAIzB,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,WAAW,CAAC;IACnB,GAAG,EAAE,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,OAAO,CAAC;IACtB,KAAK,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,QAAQ,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,wBAAwB,GAAG,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,IAAI,CAAC;AAC5E,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;AAatD,qBAAa,SAAS;IACpB,OAAO,CAAC,GAAG,CAAgB;IAC3B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,WAAW,CAA+B;IAClD,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,mBAAmB,CAAkC;IAC7D,OAAO,CAAC,cAAc,CAAuB;gBAEjC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;IAQrD,OAAO,CAAC,WAAW,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAoC1D,UAAU,IAAI,IAAI;IAUlB,MAAM,CAAC,WAAW,EAAE,cAAc,GAAG,IAAI;IAchD,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,aAAa;IAQd,YAAY,IAAI,IAAI;IAQd,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAa7F,OAAO,CAAC,qBAAqB;IAStB,YAAY,CAAC,EAAE,EAAE,wBAAwB,GAAG,IAAI;IAM1C,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IA4B3E,OAAO,CAAC,EAAE,EAAE,aAAa,GAAG,IAAI;IAMvC,OAAO,CAAC,wBAAwB;IAiBzB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAI7B,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;CAG/C"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vemjs/lsp-client — Language Server Protocol Client
|
|
3
|
+
*
|
|
4
|
+
* Implements the LSP specification subset needed for:
|
|
5
|
+
* - Initialize / Shutdown handshake
|
|
6
|
+
* - textDocument/didOpen, didChange, didClose
|
|
7
|
+
* - textDocument/completion (autocomplete)
|
|
8
|
+
* - textDocument/publishDiagnostics (error / warning highlights)
|
|
9
|
+
* - textDocument/hover
|
|
10
|
+
*/
|
|
11
|
+
import { JsonRpcClient } from './JsonRpcClient';
|
|
12
|
+
// ── Severity mapping ────────────────────────────────────────────────────────
|
|
13
|
+
const LSP_SEVERITY_MAP = {
|
|
14
|
+
1: 'error',
|
|
15
|
+
2: 'warning',
|
|
16
|
+
3: 'info',
|
|
17
|
+
4: 'hint',
|
|
18
|
+
};
|
|
19
|
+
// ── LSPClient ───────────────────────────────────────────────────────────────
|
|
20
|
+
export class LSPClient {
|
|
21
|
+
rpc;
|
|
22
|
+
fileUri;
|
|
23
|
+
languageId;
|
|
24
|
+
editorState = null;
|
|
25
|
+
version = 0;
|
|
26
|
+
initialized = false;
|
|
27
|
+
completionCallbacks = [];
|
|
28
|
+
hoverCallbacks = [];
|
|
29
|
+
constructor(serverUrl, fileUri, languageId) {
|
|
30
|
+
this.rpc = new JsonRpcClient(serverUrl);
|
|
31
|
+
this.fileUri = fileUri;
|
|
32
|
+
this.languageId = languageId;
|
|
33
|
+
}
|
|
34
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
35
|
+
async connect(editorState) {
|
|
36
|
+
await this.rpc.connect();
|
|
37
|
+
if (editorState) {
|
|
38
|
+
this.attach(editorState);
|
|
39
|
+
}
|
|
40
|
+
// LSP initialize handshake
|
|
41
|
+
await this.rpc.sendRequest('initialize', {
|
|
42
|
+
processId: null,
|
|
43
|
+
clientInfo: { name: 'vem', version: '0.1.0' },
|
|
44
|
+
rootUri: null,
|
|
45
|
+
capabilities: {
|
|
46
|
+
textDocument: {
|
|
47
|
+
synchronization: { didOpen: true, didChange: true, didClose: true },
|
|
48
|
+
completion: { completionItem: { snippetSupport: false } },
|
|
49
|
+
hover: {},
|
|
50
|
+
publishDiagnostics: {},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
this.rpc.sendNotification('initialized', {});
|
|
55
|
+
this.initialized = true;
|
|
56
|
+
// Register diagnostics listener
|
|
57
|
+
this.rpc.onNotification('textDocument/publishDiagnostics', (params) => {
|
|
58
|
+
this.handlePublishDiagnostics(params);
|
|
59
|
+
});
|
|
60
|
+
// If editor was already open, sync immediately
|
|
61
|
+
if (this.editorState) {
|
|
62
|
+
this.sendDidOpen();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
disconnect() {
|
|
66
|
+
if (this.initialized) {
|
|
67
|
+
this.rpc.sendNotification('exit', undefined);
|
|
68
|
+
}
|
|
69
|
+
this.rpc.disconnect();
|
|
70
|
+
this.initialized = false;
|
|
71
|
+
}
|
|
72
|
+
// ── Editor state binding ──────────────────────────────────────────────────
|
|
73
|
+
attach(editorState) {
|
|
74
|
+
this.editorState = editorState;
|
|
75
|
+
editorState.onDidOpenBuffer(() => {
|
|
76
|
+
if (this.initialized)
|
|
77
|
+
this.sendDidOpen();
|
|
78
|
+
});
|
|
79
|
+
editorState.onDidChangeBuffer(() => {
|
|
80
|
+
if (this.initialized)
|
|
81
|
+
this.sendDidChange();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// ── textDocument sync ─────────────────────────────────────────────────────
|
|
85
|
+
sendDidOpen() {
|
|
86
|
+
if (!this.editorState)
|
|
87
|
+
return;
|
|
88
|
+
this.rpc.sendNotification('textDocument/didOpen', {
|
|
89
|
+
textDocument: {
|
|
90
|
+
uri: this.fileUri,
|
|
91
|
+
languageId: this.languageId,
|
|
92
|
+
version: ++this.version,
|
|
93
|
+
text: this.editorState.getText(),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
sendDidChange() {
|
|
98
|
+
if (!this.editorState)
|
|
99
|
+
return;
|
|
100
|
+
this.rpc.sendNotification('textDocument/didChange', {
|
|
101
|
+
textDocument: { uri: this.fileUri, version: ++this.version },
|
|
102
|
+
contentChanges: [{ text: this.editorState.getText() }],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
sendDidClose() {
|
|
106
|
+
this.rpc.sendNotification('textDocument/didClose', {
|
|
107
|
+
textDocument: { uri: this.fileUri },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// ── Completion ────────────────────────────────────────────────────────────
|
|
111
|
+
async requestCompletion(line, character) {
|
|
112
|
+
if (!this.initialized)
|
|
113
|
+
return [];
|
|
114
|
+
const result = await this.rpc.sendRequest('textDocument/completion', {
|
|
115
|
+
textDocument: { uri: this.fileUri },
|
|
116
|
+
position: { line, character },
|
|
117
|
+
});
|
|
118
|
+
const items = this.parseCompletionResult(result);
|
|
119
|
+
for (const cb of this.completionCallbacks)
|
|
120
|
+
cb(items);
|
|
121
|
+
return items;
|
|
122
|
+
}
|
|
123
|
+
parseCompletionResult(result) {
|
|
124
|
+
if (!result)
|
|
125
|
+
return [];
|
|
126
|
+
if (Array.isArray(result))
|
|
127
|
+
return result;
|
|
128
|
+
if (typeof result === 'object' && 'items' in result) {
|
|
129
|
+
return result.items;
|
|
130
|
+
}
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
onCompletion(cb) {
|
|
134
|
+
this.completionCallbacks.push(cb);
|
|
135
|
+
}
|
|
136
|
+
// ── Hover ─────────────────────────────────────────────────────────────────
|
|
137
|
+
async requestHover(line, character) {
|
|
138
|
+
if (!this.initialized)
|
|
139
|
+
return null;
|
|
140
|
+
const result = await this.rpc.sendRequest('textDocument/hover', {
|
|
141
|
+
textDocument: { uri: this.fileUri },
|
|
142
|
+
position: { line, character },
|
|
143
|
+
});
|
|
144
|
+
if (!result || typeof result !== 'object')
|
|
145
|
+
return null;
|
|
146
|
+
const hover = result;
|
|
147
|
+
let text = null;
|
|
148
|
+
if (typeof hover.contents === 'string') {
|
|
149
|
+
text = hover.contents;
|
|
150
|
+
}
|
|
151
|
+
else if (typeof hover.contents === 'object' &&
|
|
152
|
+
hover.contents !== null &&
|
|
153
|
+
'value' in hover.contents) {
|
|
154
|
+
text = hover.contents.value;
|
|
155
|
+
}
|
|
156
|
+
if (text) {
|
|
157
|
+
for (const cb of this.hoverCallbacks)
|
|
158
|
+
cb(text);
|
|
159
|
+
}
|
|
160
|
+
return text;
|
|
161
|
+
}
|
|
162
|
+
onHover(cb) {
|
|
163
|
+
this.hoverCallbacks.push(cb);
|
|
164
|
+
}
|
|
165
|
+
// ── Diagnostics ───────────────────────────────────────────────────────────
|
|
166
|
+
handlePublishDiagnostics(params) {
|
|
167
|
+
if (!this.editorState || params.uri !== this.fileUri)
|
|
168
|
+
return;
|
|
169
|
+
const mapped = params.diagnostics.map((d) => ({
|
|
170
|
+
line: d.range.start.line,
|
|
171
|
+
startCharacter: d.range.start.character,
|
|
172
|
+
endCharacter: d.range.end.character,
|
|
173
|
+
severity: LSP_SEVERITY_MAP[d.severity ?? 1] ?? 'error',
|
|
174
|
+
message: d.message,
|
|
175
|
+
source: d.source,
|
|
176
|
+
}));
|
|
177
|
+
this.editorState.setDiagnostics(mapped);
|
|
178
|
+
}
|
|
179
|
+
// ── Workspace ─────────────────────────────────────────────────────────────
|
|
180
|
+
setFileUri(uri) {
|
|
181
|
+
this.fileUri = uri;
|
|
182
|
+
}
|
|
183
|
+
setLanguageId(languageId) {
|
|
184
|
+
this.languageId = languageId;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vemjs/lsp-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LSP protocol client for the Vem editor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@vemjs/core": "workspace:*"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vemjs/lsp-client — JSON-RPC 2.0 Protocol Engine
|
|
3
|
+
*
|
|
4
|
+
* Handles request/response correlation and notification routing
|
|
5
|
+
* over a WebSocket transport layer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface JsonRpcRequest {
|
|
9
|
+
jsonrpc: '2.0';
|
|
10
|
+
id: number;
|
|
11
|
+
method: string;
|
|
12
|
+
params?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface JsonRpcNotification {
|
|
16
|
+
jsonrpc: '2.0';
|
|
17
|
+
method: string;
|
|
18
|
+
params?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JsonRpcResponse {
|
|
22
|
+
jsonrpc: '2.0';
|
|
23
|
+
id: number;
|
|
24
|
+
result?: unknown;
|
|
25
|
+
error?: { code: number; message: string; data?: unknown };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
29
|
+
|
|
30
|
+
export type NotificationHandler = (params: unknown) => void;
|
|
31
|
+
|
|
32
|
+
export class JsonRpcClient {
|
|
33
|
+
private ws: WebSocket | null = null;
|
|
34
|
+
private nextId = 1;
|
|
35
|
+
private pending = new Map<
|
|
36
|
+
number,
|
|
37
|
+
{ resolve: (v: unknown) => void; reject: (e: unknown) => void }
|
|
38
|
+
>();
|
|
39
|
+
private notificationHandlers = new Map<string, NotificationHandler[]>();
|
|
40
|
+
private readyQueue: (() => void)[] = [];
|
|
41
|
+
private connected = false;
|
|
42
|
+
private readonly url: string;
|
|
43
|
+
|
|
44
|
+
constructor(url: string) {
|
|
45
|
+
this.url = url;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Connection ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
public connect(): Promise<void> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
this.ws = new WebSocket(this.url);
|
|
53
|
+
|
|
54
|
+
this.ws.onopen = () => {
|
|
55
|
+
this.connected = true;
|
|
56
|
+
for (const fn of this.readyQueue) fn();
|
|
57
|
+
this.readyQueue = [];
|
|
58
|
+
resolve();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
this.ws.onmessage = (ev) => {
|
|
62
|
+
try {
|
|
63
|
+
this.handleMessage(JSON.parse(ev.data as string) as JsonRpcMessage);
|
|
64
|
+
} catch {
|
|
65
|
+
console.warn('[JsonRpcClient] Failed to parse message:', ev.data);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this.ws.onerror = (ev) => {
|
|
70
|
+
reject(ev);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
this.ws.onclose = () => {
|
|
74
|
+
this.connected = false;
|
|
75
|
+
// Reject all pending requests on disconnect
|
|
76
|
+
for (const [id, p] of this.pending) {
|
|
77
|
+
p.reject(new Error(`Connection closed before response for id=${id}`));
|
|
78
|
+
}
|
|
79
|
+
this.pending.clear();
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public disconnect(): void {
|
|
85
|
+
this.ws?.close();
|
|
86
|
+
this.ws = null;
|
|
87
|
+
this.connected = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public get isConnected(): boolean {
|
|
91
|
+
return this.connected;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Outbound ───────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
public sendRequest(method: string, params?: unknown): Promise<unknown> {
|
|
97
|
+
const id = this.nextId++;
|
|
98
|
+
const message: JsonRpcRequest = {
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id,
|
|
101
|
+
method,
|
|
102
|
+
...(params !== undefined && { params }),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
this.pending.set(id, { resolve, reject });
|
|
107
|
+
this.send(message);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public sendNotification(method: string, params?: unknown): void {
|
|
112
|
+
const message: JsonRpcNotification = {
|
|
113
|
+
jsonrpc: '2.0',
|
|
114
|
+
method,
|
|
115
|
+
...(params !== undefined && { params }),
|
|
116
|
+
};
|
|
117
|
+
this.send(message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Inbound ────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
public onNotification(method: string, cb: NotificationHandler): void {
|
|
123
|
+
const handlers = this.notificationHandlers.get(method) ?? [];
|
|
124
|
+
handlers.push(cb);
|
|
125
|
+
this.notificationHandlers.set(method, handlers);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Internal ───────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
private send(message: JsonRpcMessage): void {
|
|
131
|
+
const send = () => this.ws?.send(JSON.stringify(message));
|
|
132
|
+
if (this.connected) {
|
|
133
|
+
send();
|
|
134
|
+
} else {
|
|
135
|
+
this.readyQueue.push(send);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleMessage(message: JsonRpcMessage): void {
|
|
140
|
+
// Response (has `id` and `result` or `error`)
|
|
141
|
+
if ('id' in message && ('result' in message || 'error' in message)) {
|
|
142
|
+
const response = message as JsonRpcResponse;
|
|
143
|
+
const pending = this.pending.get(response.id);
|
|
144
|
+
if (pending) {
|
|
145
|
+
this.pending.delete(response.id);
|
|
146
|
+
if (response.error) {
|
|
147
|
+
pending.reject(response.error);
|
|
148
|
+
} else {
|
|
149
|
+
pending.resolve(response.result);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Notification (no `id`)
|
|
156
|
+
if ('method' in message && !('id' in message)) {
|
|
157
|
+
const notification = message as JsonRpcNotification;
|
|
158
|
+
const handlers = this.notificationHandlers.get(notification.method);
|
|
159
|
+
if (handlers) {
|
|
160
|
+
for (const h of handlers) h(notification.params);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { VemEditorState } from '@vemjs/core';
|
|
3
|
+
import { JsonRpcClient } from './JsonRpcClient';
|
|
4
|
+
import { LSPClient } from './index';
|
|
5
|
+
|
|
6
|
+
// ── Mock WebSocket ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface SentMessage {
|
|
9
|
+
jsonrpc: string;
|
|
10
|
+
id?: number;
|
|
11
|
+
method: string;
|
|
12
|
+
params?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class MockWebSocket {
|
|
16
|
+
static instances: MockWebSocket[] = [];
|
|
17
|
+
|
|
18
|
+
onopen: (() => void) | null = null;
|
|
19
|
+
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
20
|
+
onerror: ((ev: unknown) => void) | null = null;
|
|
21
|
+
onclose: (() => void) | null = null;
|
|
22
|
+
|
|
23
|
+
sentMessages: SentMessage[] = [];
|
|
24
|
+
readyState = 1; // OPEN
|
|
25
|
+
|
|
26
|
+
constructor(_url: string) {
|
|
27
|
+
MockWebSocket.instances.push(this);
|
|
28
|
+
// Simulate async open
|
|
29
|
+
Promise.resolve().then(() => this.onopen?.());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
send(data: string) {
|
|
33
|
+
this.sentMessages.push(JSON.parse(data) as SentMessage);
|
|
34
|
+
|
|
35
|
+
// Auto-respond to requests with a mock result
|
|
36
|
+
const msg = JSON.parse(data) as SentMessage;
|
|
37
|
+
if (msg.id !== undefined) {
|
|
38
|
+
Promise.resolve().then(() => {
|
|
39
|
+
this.onmessage?.({
|
|
40
|
+
data: JSON.stringify({
|
|
41
|
+
jsonrpc: '2.0',
|
|
42
|
+
id: msg.id,
|
|
43
|
+
result:
|
|
44
|
+
msg.method === 'textDocument/completion'
|
|
45
|
+
? { isIncomplete: false, items: [{ label: 'console', kind: 6 }] }
|
|
46
|
+
: {},
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
close() {
|
|
54
|
+
this.readyState = 3;
|
|
55
|
+
this.onclose?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Helper: simulate a server push notification */
|
|
59
|
+
pushNotification(method: string, params: unknown) {
|
|
60
|
+
this.onmessage?.({
|
|
61
|
+
data: JSON.stringify({ jsonrpc: '2.0', method, params }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Inject mock globally
|
|
67
|
+
(globalThis as unknown as Record<string, unknown>).WebSocket = MockWebSocket;
|
|
68
|
+
|
|
69
|
+
// ── JsonRpcClient tests ──────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('JsonRpcClient', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
MockWebSocket.instances = [];
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should connect and resolve sendRequest via mock response', async () => {
|
|
77
|
+
const client = new JsonRpcClient('ws://localhost:2087');
|
|
78
|
+
await client.connect();
|
|
79
|
+
|
|
80
|
+
const ws = MockWebSocket.instances[0];
|
|
81
|
+
expect(ws).toBeDefined();
|
|
82
|
+
expect(client.isConnected).toBe(true);
|
|
83
|
+
|
|
84
|
+
const result = await client.sendRequest('textDocument/completion', {
|
|
85
|
+
textDocument: { uri: 'file:///test.ts' },
|
|
86
|
+
position: { line: 0, character: 5 },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should dispatch onNotification handlers', async () => {
|
|
93
|
+
const client = new JsonRpcClient('ws://localhost:2087');
|
|
94
|
+
await client.connect();
|
|
95
|
+
|
|
96
|
+
let received: unknown = null;
|
|
97
|
+
client.onNotification('textDocument/publishDiagnostics', (params) => {
|
|
98
|
+
received = params;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const ws = MockWebSocket.instances[0];
|
|
102
|
+
ws.pushNotification('textDocument/publishDiagnostics', {
|
|
103
|
+
uri: 'file:///test.ts',
|
|
104
|
+
diagnostics: [],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Give the microtask queue a chance to flush
|
|
108
|
+
await Promise.resolve();
|
|
109
|
+
expect(received).not.toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should send notifications without expecting a response', async () => {
|
|
113
|
+
const client = new JsonRpcClient('ws://localhost:2087');
|
|
114
|
+
await client.connect();
|
|
115
|
+
|
|
116
|
+
client.sendNotification('textDocument/didClose', {
|
|
117
|
+
textDocument: { uri: 'file:///test.ts' },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const ws = MockWebSocket.instances[0];
|
|
121
|
+
const notif = ws.sentMessages.find((m) => m.method === 'textDocument/didClose');
|
|
122
|
+
expect(notif).toBeDefined();
|
|
123
|
+
expect(notif?.id).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── LSPClient integration tests ──────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('LSPClient', () => {
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
MockWebSocket.instances = [];
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should sync buffer text on didOpen after connect', async () => {
|
|
135
|
+
const editor = new VemEditorState('const x = 1;');
|
|
136
|
+
const lsp = new LSPClient('ws://localhost:2087', 'file:///test.ts', 'typescript');
|
|
137
|
+
await lsp.connect(editor);
|
|
138
|
+
|
|
139
|
+
const ws = MockWebSocket.instances[0];
|
|
140
|
+
// Wait for the async didOpen triggered by the setTimeout in editor state
|
|
141
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
142
|
+
|
|
143
|
+
const didOpen = ws.sentMessages.find((m) => m.method === 'textDocument/didOpen');
|
|
144
|
+
expect(didOpen).toBeDefined();
|
|
145
|
+
const doc = (didOpen?.params as { textDocument: { text: string } })?.textDocument;
|
|
146
|
+
expect(doc?.text).toBe('const x = 1;');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should update diagnostics in editor state on publishDiagnostics', async () => {
|
|
150
|
+
const editor = new VemEditorState('let x: number = "oops";');
|
|
151
|
+
const lsp = new LSPClient('ws://localhost:2087', 'file:///test.ts', 'typescript');
|
|
152
|
+
await lsp.connect(editor);
|
|
153
|
+
|
|
154
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
155
|
+
|
|
156
|
+
const ws = MockWebSocket.instances[0];
|
|
157
|
+
ws.pushNotification('textDocument/publishDiagnostics', {
|
|
158
|
+
uri: 'file:///test.ts',
|
|
159
|
+
diagnostics: [
|
|
160
|
+
{
|
|
161
|
+
range: {
|
|
162
|
+
start: { line: 0, character: 16 },
|
|
163
|
+
end: { line: 0, character: 22 },
|
|
164
|
+
},
|
|
165
|
+
severity: 1,
|
|
166
|
+
message: "Type 'string' is not assignable to type 'number'.",
|
|
167
|
+
source: 'tsserver',
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
const diags = editor.getDiagnostics();
|
|
174
|
+
expect(diags).toHaveLength(1);
|
|
175
|
+
expect(diags[0].severity).toBe('error');
|
|
176
|
+
expect(diags[0].line).toBe(0);
|
|
177
|
+
expect(diags[0].source).toBe('tsserver');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should fire onPublishDiagnostics callback when diagnostics arrive', async () => {
|
|
181
|
+
const editor = new VemEditorState('');
|
|
182
|
+
const lsp = new LSPClient('ws://localhost:2087', 'file:///test.ts', 'typescript');
|
|
183
|
+
await lsp.connect(editor);
|
|
184
|
+
|
|
185
|
+
let callbackFired = false;
|
|
186
|
+
editor.onPublishDiagnostics((_diags) => {
|
|
187
|
+
callbackFired = true;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
191
|
+
const ws = MockWebSocket.instances[0];
|
|
192
|
+
ws.pushNotification('textDocument/publishDiagnostics', {
|
|
193
|
+
uri: 'file:///test.ts',
|
|
194
|
+
diagnostics: [],
|
|
195
|
+
});
|
|
196
|
+
await Promise.resolve();
|
|
197
|
+
|
|
198
|
+
// setDiagnostics fires callbacks even for empty arrays
|
|
199
|
+
expect(callbackFired).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should request completions and return items', async () => {
|
|
203
|
+
const editor = new VemEditorState('console');
|
|
204
|
+
const lsp = new LSPClient('ws://localhost:2087', 'file:///test.ts', 'typescript');
|
|
205
|
+
await lsp.connect(editor);
|
|
206
|
+
|
|
207
|
+
const items = await lsp.requestCompletion(0, 7);
|
|
208
|
+
expect(items.length).toBeGreaterThan(0);
|
|
209
|
+
expect(items[0].label).toBe('console');
|
|
210
|
+
});
|
|
211
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vemjs/lsp-client — Language Server Protocol Client
|
|
3
|
+
*
|
|
4
|
+
* Implements the LSP specification subset needed for:
|
|
5
|
+
* - Initialize / Shutdown handshake
|
|
6
|
+
* - textDocument/didOpen, didChange, didClose
|
|
7
|
+
* - textDocument/completion (autocomplete)
|
|
8
|
+
* - textDocument/publishDiagnostics (error / warning highlights)
|
|
9
|
+
* - textDocument/hover
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { VemEditorState, Diagnostic, DiagnosticSeverity } from '@vemjs/core';
|
|
13
|
+
import { JsonRpcClient } from './JsonRpcClient';
|
|
14
|
+
export type {
|
|
15
|
+
JsonRpcRequest,
|
|
16
|
+
JsonRpcNotification,
|
|
17
|
+
JsonRpcResponse,
|
|
18
|
+
JsonRpcMessage,
|
|
19
|
+
NotificationHandler,
|
|
20
|
+
} from './JsonRpcClient';
|
|
21
|
+
|
|
22
|
+
// ── LSP Types (subset of the spec) ─────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface LspPosition {
|
|
25
|
+
line: number;
|
|
26
|
+
character: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LspRange {
|
|
30
|
+
start: LspPosition;
|
|
31
|
+
end: LspPosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LspTextDocumentIdentifier {
|
|
35
|
+
uri: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LspTextDocumentItem {
|
|
39
|
+
uri: string;
|
|
40
|
+
languageId: string;
|
|
41
|
+
version: number;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LspCompletionItem {
|
|
46
|
+
label: string;
|
|
47
|
+
kind?: number;
|
|
48
|
+
detail?: string;
|
|
49
|
+
insertText?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LspCompletionList {
|
|
53
|
+
isIncomplete: boolean;
|
|
54
|
+
items: LspCompletionItem[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LspDiagnostic {
|
|
58
|
+
range: LspRange;
|
|
59
|
+
severity?: 1 | 2 | 3 | 4; // error, warning, info, hint
|
|
60
|
+
message: string;
|
|
61
|
+
source?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CompletionResultCallback = (items: LspCompletionItem[]) => void;
|
|
65
|
+
export type HoverCallback = (content: string) => void;
|
|
66
|
+
|
|
67
|
+
// ── Severity mapping ────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const LSP_SEVERITY_MAP: Record<number, DiagnosticSeverity> = {
|
|
70
|
+
1: 'error',
|
|
71
|
+
2: 'warning',
|
|
72
|
+
3: 'info',
|
|
73
|
+
4: 'hint',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ── LSPClient ───────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export class LSPClient {
|
|
79
|
+
private rpc: JsonRpcClient;
|
|
80
|
+
private fileUri: string;
|
|
81
|
+
private languageId: string;
|
|
82
|
+
private editorState: VemEditorState | null = null;
|
|
83
|
+
private version = 0;
|
|
84
|
+
private initialized = false;
|
|
85
|
+
private completionCallbacks: CompletionResultCallback[] = [];
|
|
86
|
+
private hoverCallbacks: HoverCallback[] = [];
|
|
87
|
+
|
|
88
|
+
constructor(serverUrl: string, fileUri: string, languageId: string) {
|
|
89
|
+
this.rpc = new JsonRpcClient(serverUrl);
|
|
90
|
+
this.fileUri = fileUri;
|
|
91
|
+
this.languageId = languageId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
public async connect(editorState?: VemEditorState): Promise<void> {
|
|
97
|
+
await this.rpc.connect();
|
|
98
|
+
|
|
99
|
+
if (editorState) {
|
|
100
|
+
this.attach(editorState);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// LSP initialize handshake
|
|
104
|
+
await this.rpc.sendRequest('initialize', {
|
|
105
|
+
processId: null,
|
|
106
|
+
clientInfo: { name: 'vem', version: '0.1.0' },
|
|
107
|
+
rootUri: null,
|
|
108
|
+
capabilities: {
|
|
109
|
+
textDocument: {
|
|
110
|
+
synchronization: { didOpen: true, didChange: true, didClose: true },
|
|
111
|
+
completion: { completionItem: { snippetSupport: false } },
|
|
112
|
+
hover: {},
|
|
113
|
+
publishDiagnostics: {},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this.rpc.sendNotification('initialized', {});
|
|
119
|
+
this.initialized = true;
|
|
120
|
+
|
|
121
|
+
// Register diagnostics listener
|
|
122
|
+
this.rpc.onNotification('textDocument/publishDiagnostics', (params: unknown) => {
|
|
123
|
+
this.handlePublishDiagnostics(params as { uri: string; diagnostics: LspDiagnostic[] });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// If editor was already open, sync immediately
|
|
127
|
+
if (this.editorState) {
|
|
128
|
+
this.sendDidOpen();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public disconnect(): void {
|
|
133
|
+
if (this.initialized) {
|
|
134
|
+
this.rpc.sendNotification('exit', undefined);
|
|
135
|
+
}
|
|
136
|
+
this.rpc.disconnect();
|
|
137
|
+
this.initialized = false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Editor state binding ──────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
public attach(editorState: VemEditorState): void {
|
|
143
|
+
this.editorState = editorState;
|
|
144
|
+
|
|
145
|
+
editorState.onDidOpenBuffer(() => {
|
|
146
|
+
if (this.initialized) this.sendDidOpen();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
editorState.onDidChangeBuffer(() => {
|
|
150
|
+
if (this.initialized) this.sendDidChange();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── textDocument sync ─────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
private sendDidOpen(): void {
|
|
157
|
+
if (!this.editorState) return;
|
|
158
|
+
this.rpc.sendNotification('textDocument/didOpen', {
|
|
159
|
+
textDocument: {
|
|
160
|
+
uri: this.fileUri,
|
|
161
|
+
languageId: this.languageId,
|
|
162
|
+
version: ++this.version,
|
|
163
|
+
text: this.editorState.getText(),
|
|
164
|
+
} satisfies LspTextDocumentItem,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private sendDidChange(): void {
|
|
169
|
+
if (!this.editorState) return;
|
|
170
|
+
this.rpc.sendNotification('textDocument/didChange', {
|
|
171
|
+
textDocument: { uri: this.fileUri, version: ++this.version },
|
|
172
|
+
contentChanges: [{ text: this.editorState.getText() }],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public sendDidClose(): void {
|
|
177
|
+
this.rpc.sendNotification('textDocument/didClose', {
|
|
178
|
+
textDocument: { uri: this.fileUri } satisfies LspTextDocumentIdentifier,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Completion ────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
public async requestCompletion(line: number, character: number): Promise<LspCompletionItem[]> {
|
|
185
|
+
if (!this.initialized) return [];
|
|
186
|
+
|
|
187
|
+
const result = await this.rpc.sendRequest('textDocument/completion', {
|
|
188
|
+
textDocument: { uri: this.fileUri },
|
|
189
|
+
position: { line, character } satisfies LspPosition,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const items = this.parseCompletionResult(result);
|
|
193
|
+
for (const cb of this.completionCallbacks) cb(items);
|
|
194
|
+
return items;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private parseCompletionResult(result: unknown): LspCompletionItem[] {
|
|
198
|
+
if (!result) return [];
|
|
199
|
+
if (Array.isArray(result)) return result as LspCompletionItem[];
|
|
200
|
+
if (typeof result === 'object' && 'items' in (result as object)) {
|
|
201
|
+
return (result as LspCompletionList).items;
|
|
202
|
+
}
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public onCompletion(cb: CompletionResultCallback): void {
|
|
207
|
+
this.completionCallbacks.push(cb);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Hover ─────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
public async requestHover(line: number, character: number): Promise<string | null> {
|
|
213
|
+
if (!this.initialized) return null;
|
|
214
|
+
|
|
215
|
+
const result = await this.rpc.sendRequest('textDocument/hover', {
|
|
216
|
+
textDocument: { uri: this.fileUri },
|
|
217
|
+
position: { line, character } satisfies LspPosition,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!result || typeof result !== 'object') return null;
|
|
221
|
+
const hover = result as { contents: unknown };
|
|
222
|
+
|
|
223
|
+
let text: string | null = null;
|
|
224
|
+
if (typeof hover.contents === 'string') {
|
|
225
|
+
text = hover.contents;
|
|
226
|
+
} else if (
|
|
227
|
+
typeof hover.contents === 'object' &&
|
|
228
|
+
hover.contents !== null &&
|
|
229
|
+
'value' in hover.contents
|
|
230
|
+
) {
|
|
231
|
+
text = (hover.contents as { value: string }).value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (text) {
|
|
235
|
+
for (const cb of this.hoverCallbacks) cb(text);
|
|
236
|
+
}
|
|
237
|
+
return text;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public onHover(cb: HoverCallback): void {
|
|
241
|
+
this.hoverCallbacks.push(cb);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Diagnostics ───────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
private handlePublishDiagnostics(params: { uri: string; diagnostics: LspDiagnostic[] }): void {
|
|
247
|
+
if (!this.editorState || params.uri !== this.fileUri) return;
|
|
248
|
+
|
|
249
|
+
const mapped: Diagnostic[] = params.diagnostics.map((d) => ({
|
|
250
|
+
line: d.range.start.line,
|
|
251
|
+
startCharacter: d.range.start.character,
|
|
252
|
+
endCharacter: d.range.end.character,
|
|
253
|
+
severity: LSP_SEVERITY_MAP[d.severity ?? 1] ?? 'error',
|
|
254
|
+
message: d.message,
|
|
255
|
+
source: d.source,
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
this.editorState.setDiagnostics(mapped);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Workspace ─────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
public setFileUri(uri: string): void {
|
|
264
|
+
this.fileUri = uri;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
public setLanguageId(languageId: string): void {
|
|
268
|
+
this.languageId = languageId;
|
|
269
|
+
}
|
|
270
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": false,
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"sourceMap": true,
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"outDir": "./dist"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"],
|
|
12
|
+
"exclude": ["src/**/*.test.ts"]
|
|
13
|
+
}
|