@dreki-gg/pi-lsp 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/README.md +131 -0
- package/extensions/lsp/client.ts +439 -0
- package/extensions/lsp/config.ts +181 -0
- package/extensions/lsp/formatting.ts +312 -0
- package/extensions/lsp/index.ts +159 -0
- package/extensions/lsp/protocol.ts +208 -0
- package/extensions/lsp/tools.ts +291 -0
- package/extensions/lsp/types.ts +243 -0
- package/package.json +40 -0
- package/test/client.test.ts +103 -0
- package/test/config.test.ts +121 -0
- package/test/formatting.test.ts +193 -0
- package/test/index.test.ts +188 -0
- package/test/mock-lsp-server.ts +294 -0
- package/test/tools.test.ts +218 -0
- package/test/typescript.integration.test.ts +158 -0
- package/tsconfig.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @dreki-gg/pi-lsp
|
|
2
|
+
|
|
3
|
+
Language-agnostic code intelligence for [pi](https://github.com/badlogic/pi-mono) via Language Server Protocol. Purely config-driven — you define which servers to use per project.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pi install npm:@dreki-gg/pi-lsp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Tool
|
|
13
|
+
|
|
14
|
+
Single unified `lsp` tool with 11 operations:
|
|
15
|
+
|
|
16
|
+
| Operation | Description | Required params |
|
|
17
|
+
|-----------|-------------|-----------------|
|
|
18
|
+
| `diagnostics` | Type errors + lint warnings | `filePath` |
|
|
19
|
+
| `hover` | Type info and documentation | `filePath`, `line`, `character` |
|
|
20
|
+
| `goToDefinition` | Find where a symbol is defined | `filePath`, `line`, `character` |
|
|
21
|
+
| `findReferences` | Find all references to a symbol | `filePath`, `line`, `character` |
|
|
22
|
+
| `goToImplementation` | Find implementations of interface/abstract | `filePath`, `line`, `character` |
|
|
23
|
+
| `documentSymbol` | List all symbols in a file | `filePath` |
|
|
24
|
+
| `workspaceSymbol` | Search symbols across the workspace | `query` |
|
|
25
|
+
| `prepareCallHierarchy` | Get call hierarchy item at position | `filePath`, `line`, `character` |
|
|
26
|
+
| `incomingCalls` | Find callers of a function | `filePath`, `line`, `character` |
|
|
27
|
+
| `outgoingCalls` | Find callees of a function | `filePath`, `line`, `character` |
|
|
28
|
+
| `codeActions` | Quick fixes and refactoring suggestions | `filePath`, `line`, `character` |
|
|
29
|
+
|
|
30
|
+
All `line`/`character` params are **1-indexed** (matching the `read` tool output).
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Servers are configured via two config files (project overrides global):
|
|
35
|
+
|
|
36
|
+
| File | Scope |
|
|
37
|
+
|------|-------|
|
|
38
|
+
| `~/.pi/agent/extensions/lsp/config.json` | Global defaults |
|
|
39
|
+
| `.pi/lsp.json` | Project-local overrides |
|
|
40
|
+
|
|
41
|
+
### Example: TypeScript + oxlint
|
|
42
|
+
|
|
43
|
+
`.pi/lsp.json`:
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"lsp": {
|
|
47
|
+
"typescript": {
|
|
48
|
+
"command": ["typescript-language-server", "--stdio"],
|
|
49
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`typescript-language-server` automatically uses the project's local `node_modules/typescript`, so your project's TS version is always respected.
|
|
56
|
+
|
|
57
|
+
### Adding other servers
|
|
58
|
+
|
|
59
|
+
`.pi/lsp.json`:
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"lsp": {
|
|
63
|
+
"rust": {
|
|
64
|
+
"command": ["rust-analyzer"],
|
|
65
|
+
"extensions": [".rs"]
|
|
66
|
+
},
|
|
67
|
+
"python": {
|
|
68
|
+
"command": ["pyright-langserver", "--stdio"],
|
|
69
|
+
"extensions": [".py"],
|
|
70
|
+
"initialization": {
|
|
71
|
+
"python": { "analysis": { "typeCheckingMode": "basic" } }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Disabling a server
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"lsp": {
|
|
83
|
+
"typescript": { "disabled": true }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Disabling all LSP
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"lsp": false
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Server config options
|
|
97
|
+
|
|
98
|
+
| Property | Type | Description |
|
|
99
|
+
|----------|------|-------------|
|
|
100
|
+
| `command` | `string[]` | Command + args to spawn (e.g. `["rust-analyzer"]`) |
|
|
101
|
+
| `extensions` | `string[]` | File extensions with leading dot |
|
|
102
|
+
| `disabled` | `boolean` | Disable this server |
|
|
103
|
+
| `env` | `object` | Environment variables for the server process |
|
|
104
|
+
| `initialization` | `object` | Options sent during LSP initialize handshake |
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
- **Auto-detection**: Servers are matched to files by extension. Multiple servers can handle the same extension.
|
|
109
|
+
- **Routing**: `diagnostics` aggregates from all matching servers. Other operations use the first server with the required capability.
|
|
110
|
+
- **Lazy start**: Servers spawn on first tool use, stay alive for the session.
|
|
111
|
+
- **Config merge**: Project `.pi/lsp.json` overrides global `~/.pi/agent/extensions/lsp/config.json`.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
| Command | Description |
|
|
116
|
+
|---------|-------------|
|
|
117
|
+
| `/lsp` | Show server status, detected servers, and extensions |
|
|
118
|
+
| `/lsp-restart` | Stop all servers (reinitialize on next tool use) |
|
|
119
|
+
|
|
120
|
+
## Architecture
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
extensions/lsp/
|
|
124
|
+
├── index.ts — Entry point, server manager, lifecycle
|
|
125
|
+
├── protocol.ts — JSON-RPC over stdio transport
|
|
126
|
+
├── client.ts — High-level LSP client (all 11 operations)
|
|
127
|
+
├── config.ts — Config loading, merging, server resolution
|
|
128
|
+
├── tools.ts — Single unified `lsp` tool registration
|
|
129
|
+
├── formatting.ts — Format all LSP responses for LLM consumption
|
|
130
|
+
└── types.ts — LSP protocol types, config types, operation enums
|
|
131
|
+
```
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level LSP client.
|
|
3
|
+
*
|
|
4
|
+
* Manages the initialize handshake, document lifecycle, diagnostic collection,
|
|
5
|
+
* and typed request helpers for all supported LSP operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { LspConnection } from './protocol';
|
|
12
|
+
import type {
|
|
13
|
+
CallHierarchyIncomingCall,
|
|
14
|
+
CallHierarchyItem,
|
|
15
|
+
CallHierarchyOutgoingCall,
|
|
16
|
+
CodeAction,
|
|
17
|
+
CodeActionContext,
|
|
18
|
+
Diagnostic,
|
|
19
|
+
DocumentSymbol,
|
|
20
|
+
Hover,
|
|
21
|
+
Location,
|
|
22
|
+
Position,
|
|
23
|
+
PublishDiagnosticsParams,
|
|
24
|
+
Range,
|
|
25
|
+
ResolvedServerConfig,
|
|
26
|
+
SymbolInformation,
|
|
27
|
+
} from './types';
|
|
28
|
+
|
|
29
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export function pathToUri(filePath: string): string {
|
|
32
|
+
const abs = filePath.startsWith('/') ? filePath : resolve(filePath);
|
|
33
|
+
return `file://${abs}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function uriToPath(uri: string): string {
|
|
37
|
+
return uri.startsWith('file://') ? uri.slice(7) : uri;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function languageIdForFile(filePath: string): string {
|
|
41
|
+
const ext = filePath.slice(filePath.lastIndexOf('.'));
|
|
42
|
+
const map: Record<string, string> = {
|
|
43
|
+
'.ts': 'typescript',
|
|
44
|
+
'.tsx': 'typescriptreact',
|
|
45
|
+
'.mts': 'typescript',
|
|
46
|
+
'.cts': 'typescript',
|
|
47
|
+
'.js': 'javascript',
|
|
48
|
+
'.jsx': 'javascriptreact',
|
|
49
|
+
'.mjs': 'javascript',
|
|
50
|
+
'.cjs': 'javascript',
|
|
51
|
+
'.vue': 'vue',
|
|
52
|
+
'.yaml': 'yaml',
|
|
53
|
+
'.yml': 'yaml',
|
|
54
|
+
'.zig': 'zig',
|
|
55
|
+
'.zon': 'zig',
|
|
56
|
+
'.py': 'python',
|
|
57
|
+
'.rs': 'rust',
|
|
58
|
+
'.go': 'go',
|
|
59
|
+
'.c': 'c',
|
|
60
|
+
'.cpp': 'cpp',
|
|
61
|
+
'.h': 'c',
|
|
62
|
+
'.hpp': 'cpp',
|
|
63
|
+
'.java': 'java',
|
|
64
|
+
'.rb': 'ruby',
|
|
65
|
+
'.lua': 'lua',
|
|
66
|
+
'.css': 'css',
|
|
67
|
+
'.html': 'html',
|
|
68
|
+
'.json': 'json',
|
|
69
|
+
'.md': 'markdown',
|
|
70
|
+
};
|
|
71
|
+
return map[ext] ?? 'plaintext';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
interface OpenDocument {
|
|
77
|
+
uri: string;
|
|
78
|
+
version: number;
|
|
79
|
+
languageId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface DiagnosticWaiter {
|
|
83
|
+
resolve: () => void;
|
|
84
|
+
timer: ReturnType<typeof setTimeout>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Client ──────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export class LspClient {
|
|
90
|
+
private connection: LspConnection;
|
|
91
|
+
private initialized = false;
|
|
92
|
+
private initializePromise: Promise<void> | null = null;
|
|
93
|
+
private openDocs = new Map<string, OpenDocument>();
|
|
94
|
+
private diagnosticStore = new Map<string, Diagnostic[]>();
|
|
95
|
+
private diagnosticWaiters = new Map<string, DiagnosticWaiter[]>();
|
|
96
|
+
private serverCapabilities: Record<string, unknown> = {};
|
|
97
|
+
private stderrLog: string[] = [];
|
|
98
|
+
|
|
99
|
+
readonly config: ResolvedServerConfig;
|
|
100
|
+
private rootPath: string;
|
|
101
|
+
|
|
102
|
+
constructor(config: ResolvedServerConfig, rootPath: string) {
|
|
103
|
+
this.config = config;
|
|
104
|
+
this.rootPath = rootPath;
|
|
105
|
+
this.connection = this.createConnection();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
private createConnection(): LspConnection {
|
|
111
|
+
const conn = new LspConnection(this.config.command, this.config.args, {
|
|
112
|
+
cwd: this.rootPath,
|
|
113
|
+
env: this.config.env,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
conn.setNotificationHandler((method, params) => {
|
|
117
|
+
if (method === 'textDocument/publishDiagnostics') {
|
|
118
|
+
const { uri, diagnostics } = params as PublishDiagnosticsParams;
|
|
119
|
+
this.diagnosticStore.set(uri, diagnostics);
|
|
120
|
+
|
|
121
|
+
const waiters = this.diagnosticWaiters.get(uri);
|
|
122
|
+
if (waiters?.length) {
|
|
123
|
+
for (const w of waiters) {
|
|
124
|
+
clearTimeout(w.timer);
|
|
125
|
+
w.resolve();
|
|
126
|
+
}
|
|
127
|
+
this.diagnosticWaiters.delete(uri);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
conn.setServerRequestHandler((id, _method, _params) => {
|
|
133
|
+
conn.sendResponse(id, null);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
conn.setStderrHandler((text) => {
|
|
137
|
+
this.stderrLog.push(text);
|
|
138
|
+
if (this.stderrLog.length > 100) this.stderrLog.shift();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
conn.setExitHandler((_code) => {
|
|
142
|
+
this.initialized = false;
|
|
143
|
+
this.initializePromise = null;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return conn;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async ensureInitialized(): Promise<void> {
|
|
150
|
+
if (this.initialized) return;
|
|
151
|
+
if (this.initializePromise) return this.initializePromise;
|
|
152
|
+
this.initializePromise = this.doInitialize();
|
|
153
|
+
return this.initializePromise;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async doInitialize(): Promise<void> {
|
|
157
|
+
if (!this.connection.alive) {
|
|
158
|
+
this.connection.spawn();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const rootUri = pathToUri(this.rootPath);
|
|
162
|
+
|
|
163
|
+
const result = (await this.connection.sendRequest('initialize', {
|
|
164
|
+
processId: process.pid,
|
|
165
|
+
rootUri,
|
|
166
|
+
rootPath: this.rootPath,
|
|
167
|
+
capabilities: {
|
|
168
|
+
textDocument: {
|
|
169
|
+
publishDiagnostics: {
|
|
170
|
+
relatedInformation: true,
|
|
171
|
+
codeDescriptionSupport: true,
|
|
172
|
+
},
|
|
173
|
+
hover: { contentFormat: ['markdown', 'plaintext'] },
|
|
174
|
+
definition: { linkSupport: false },
|
|
175
|
+
references: {},
|
|
176
|
+
implementation: {},
|
|
177
|
+
codeAction: {
|
|
178
|
+
codeActionLiteralSupport: {
|
|
179
|
+
codeActionKind: { valueSet: ['quickfix', 'refactor', 'source'] },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
documentSymbol: {
|
|
183
|
+
hierarchicalDocumentSymbolSupport: true,
|
|
184
|
+
},
|
|
185
|
+
callHierarchy: {},
|
|
186
|
+
synchronization: {
|
|
187
|
+
didSave: true,
|
|
188
|
+
willSave: false,
|
|
189
|
+
willSaveWaitUntil: false,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
workspace: {
|
|
193
|
+
workspaceFolders: true,
|
|
194
|
+
configuration: true,
|
|
195
|
+
symbol: {},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
workspaceFolders: [{ uri: rootUri, name: this.rootPath.split('/').pop() || 'workspace' }],
|
|
199
|
+
initializationOptions: this.config.initializationOptions,
|
|
200
|
+
})) as { capabilities?: Record<string, unknown> } | null;
|
|
201
|
+
|
|
202
|
+
this.serverCapabilities = result?.capabilities ?? {};
|
|
203
|
+
this.connection.sendNotification('initialized', {});
|
|
204
|
+
this.initialized = true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async shutdown(): Promise<void> {
|
|
208
|
+
if (!this.connection.alive) return;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
if (this.initialized) {
|
|
212
|
+
await this.connection.sendRequest('shutdown', null, 5_000);
|
|
213
|
+
this.connection.sendNotification('exit', null);
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Best-effort
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.connection.dispose();
|
|
220
|
+
this.initialized = false;
|
|
221
|
+
this.initializePromise = null;
|
|
222
|
+
this.openDocs.clear();
|
|
223
|
+
this.diagnosticStore.clear();
|
|
224
|
+
this.clearAllWaiters();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
get isInitialized(): boolean {
|
|
228
|
+
return this.initialized;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get capabilities(): Record<string, unknown> {
|
|
232
|
+
return this.serverCapabilities;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Check if the server advertised a specific capability. */
|
|
236
|
+
hasCapability(name: string): boolean {
|
|
237
|
+
return this.serverCapabilities[name] !== undefined && this.serverCapabilities[name] !== false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Document management ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async openDocument(filePath: string): Promise<string> {
|
|
243
|
+
await this.ensureInitialized();
|
|
244
|
+
|
|
245
|
+
const uri = pathToUri(resolve(this.rootPath, filePath));
|
|
246
|
+
const existing = this.openDocs.get(uri);
|
|
247
|
+
|
|
248
|
+
const absolutePath = resolve(this.rootPath, filePath);
|
|
249
|
+
const text = await readFile(absolutePath, 'utf8');
|
|
250
|
+
const languageId = languageIdForFile(filePath);
|
|
251
|
+
|
|
252
|
+
if (existing) {
|
|
253
|
+
existing.version++;
|
|
254
|
+
this.connection.sendNotification('textDocument/didChange', {
|
|
255
|
+
textDocument: { uri, version: existing.version },
|
|
256
|
+
contentChanges: [{ text }],
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
const version = 1;
|
|
260
|
+
this.connection.sendNotification('textDocument/didOpen', {
|
|
261
|
+
textDocument: { uri, languageId, version, text },
|
|
262
|
+
});
|
|
263
|
+
this.openDocs.set(uri, { uri, version, languageId });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return uri;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async closeDocument(filePath: string): Promise<void> {
|
|
270
|
+
const uri = pathToUri(resolve(this.rootPath, filePath));
|
|
271
|
+
const doc = this.openDocs.get(uri);
|
|
272
|
+
if (!doc) return;
|
|
273
|
+
|
|
274
|
+
this.connection.sendNotification('textDocument/didClose', {
|
|
275
|
+
textDocument: { uri },
|
|
276
|
+
});
|
|
277
|
+
this.openDocs.delete(uri);
|
|
278
|
+
this.diagnosticStore.delete(uri);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Diagnostics ───────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
async getDiagnostics(filePath: string, timeoutMs = 10_000): Promise<Diagnostic[]> {
|
|
284
|
+
const uri = await this.openDocument(filePath);
|
|
285
|
+
await this.waitForDiagnostics(uri, timeoutMs);
|
|
286
|
+
return this.diagnosticStore.get(uri) ?? [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private waitForDiagnostics(uri: string, timeoutMs: number): Promise<void> {
|
|
290
|
+
return new Promise<void>((resolveWait) => {
|
|
291
|
+
const existing = this.diagnosticStore.get(uri);
|
|
292
|
+
if (existing !== undefined) {
|
|
293
|
+
setTimeout(resolveWait, 500);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const timer = setTimeout(() => {
|
|
298
|
+
const waiters = this.diagnosticWaiters.get(uri);
|
|
299
|
+
if (waiters) {
|
|
300
|
+
const idx = waiters.findIndex((w) => w.resolve === resolveWait);
|
|
301
|
+
if (idx !== -1) waiters.splice(idx, 1);
|
|
302
|
+
if (waiters.length === 0) this.diagnosticWaiters.delete(uri);
|
|
303
|
+
}
|
|
304
|
+
resolveWait();
|
|
305
|
+
}, timeoutMs);
|
|
306
|
+
|
|
307
|
+
const waiters = this.diagnosticWaiters.get(uri) ?? [];
|
|
308
|
+
waiters.push({ resolve: resolveWait, timer });
|
|
309
|
+
this.diagnosticWaiters.set(uri, waiters);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private clearAllWaiters(): void {
|
|
314
|
+
for (const [, waiters] of this.diagnosticWaiters) {
|
|
315
|
+
for (const w of waiters) {
|
|
316
|
+
clearTimeout(w.timer);
|
|
317
|
+
w.resolve();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.diagnosticWaiters.clear();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Hover ─────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
async hover(filePath: string, position: Position): Promise<Hover | null> {
|
|
326
|
+
const uri = await this.openDocument(filePath);
|
|
327
|
+
const result = await this.connection.sendRequest('textDocument/hover', {
|
|
328
|
+
textDocument: { uri },
|
|
329
|
+
position,
|
|
330
|
+
});
|
|
331
|
+
return (result as Hover) ?? null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Definition ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
async definition(filePath: string, position: Position): Promise<Location[]> {
|
|
337
|
+
const uri = await this.openDocument(filePath);
|
|
338
|
+
const result = await this.connection.sendRequest('textDocument/definition', {
|
|
339
|
+
textDocument: { uri },
|
|
340
|
+
position,
|
|
341
|
+
});
|
|
342
|
+
return normalizeLocations(result);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── References ────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async references(filePath: string, position: Position): Promise<Location[]> {
|
|
348
|
+
const uri = await this.openDocument(filePath);
|
|
349
|
+
const result = await this.connection.sendRequest('textDocument/references', {
|
|
350
|
+
textDocument: { uri },
|
|
351
|
+
position,
|
|
352
|
+
context: { includeDeclaration: true },
|
|
353
|
+
});
|
|
354
|
+
return normalizeLocations(result);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Implementation ────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
async implementation(filePath: string, position: Position): Promise<Location[]> {
|
|
360
|
+
const uri = await this.openDocument(filePath);
|
|
361
|
+
const result = await this.connection.sendRequest('textDocument/implementation', {
|
|
362
|
+
textDocument: { uri },
|
|
363
|
+
position,
|
|
364
|
+
});
|
|
365
|
+
return normalizeLocations(result);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Document Symbols ──────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
async documentSymbol(filePath: string): Promise<DocumentSymbol[] | SymbolInformation[]> {
|
|
371
|
+
const uri = await this.openDocument(filePath);
|
|
372
|
+
const result = await this.connection.sendRequest('textDocument/documentSymbol', {
|
|
373
|
+
textDocument: { uri },
|
|
374
|
+
});
|
|
375
|
+
if (!Array.isArray(result)) return [];
|
|
376
|
+
return result as DocumentSymbol[] | SymbolInformation[];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Workspace Symbols ─────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
async workspaceSymbol(query: string): Promise<SymbolInformation[]> {
|
|
382
|
+
await this.ensureInitialized();
|
|
383
|
+
const result = await this.connection.sendRequest('workspace/symbol', { query });
|
|
384
|
+
if (!Array.isArray(result)) return [];
|
|
385
|
+
return result as SymbolInformation[];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Call Hierarchy ────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
async prepareCallHierarchy(filePath: string, position: Position): Promise<CallHierarchyItem[]> {
|
|
391
|
+
const uri = await this.openDocument(filePath);
|
|
392
|
+
const result = await this.connection.sendRequest('textDocument/prepareCallHierarchy', {
|
|
393
|
+
textDocument: { uri },
|
|
394
|
+
position,
|
|
395
|
+
});
|
|
396
|
+
if (!Array.isArray(result)) return [];
|
|
397
|
+
return result as CallHierarchyItem[];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async incomingCalls(item: CallHierarchyItem): Promise<CallHierarchyIncomingCall[]> {
|
|
401
|
+
const result = await this.connection.sendRequest('callHierarchy/incomingCalls', { item });
|
|
402
|
+
if (!Array.isArray(result)) return [];
|
|
403
|
+
return result as CallHierarchyIncomingCall[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async outgoingCalls(item: CallHierarchyItem): Promise<CallHierarchyOutgoingCall[]> {
|
|
407
|
+
const result = await this.connection.sendRequest('callHierarchy/outgoingCalls', { item });
|
|
408
|
+
if (!Array.isArray(result)) return [];
|
|
409
|
+
return result as CallHierarchyOutgoingCall[];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Code Actions ──────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
async codeActions(
|
|
415
|
+
filePath: string,
|
|
416
|
+
range: Range,
|
|
417
|
+
context: CodeActionContext,
|
|
418
|
+
): Promise<CodeAction[]> {
|
|
419
|
+
const uri = await this.openDocument(filePath);
|
|
420
|
+
const result = await this.connection.sendRequest('textDocument/codeAction', {
|
|
421
|
+
textDocument: { uri },
|
|
422
|
+
range,
|
|
423
|
+
context,
|
|
424
|
+
});
|
|
425
|
+
if (!Array.isArray(result)) return [];
|
|
426
|
+
return result as CodeAction[];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function normalizeLocations(result: unknown): Location[] {
|
|
433
|
+
if (!result) return [];
|
|
434
|
+
if (Array.isArray(result)) return result as Location[];
|
|
435
|
+
if (typeof result === 'object' && 'uri' in (result as Record<string, unknown>)) {
|
|
436
|
+
return [result as Location];
|
|
437
|
+
}
|
|
438
|
+
return [];
|
|
439
|
+
}
|