@theupsider/lsp-mcp 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/LICENSE +173 -0
- package/README.md +188 -0
- package/dist/__tests__/index.test.js +45 -0
- package/dist/detection/__tests__/language-detector.test.js +121 -0
- package/dist/detection/__tests__/lsp-mapping.test.js +78 -0
- package/dist/detection/language-detector.js +107 -0
- package/dist/detection/lsp-mapping.js +86 -0
- package/dist/index.js +60 -0
- package/dist/lsp/__tests__/installer.test.js +113 -0
- package/dist/lsp/__tests__/lifecycle-manager.test.js +288 -0
- package/dist/lsp/__tests__/lsp-client.test.js +238 -0
- package/dist/lsp/installer.js +65 -0
- package/dist/lsp/lifecycle-manager.js +272 -0
- package/dist/lsp/lsp-client.js +226 -0
- package/dist/mcp/__tests__/formatters.test.js +153 -0
- package/dist/mcp/__tests__/read-tools.test.js +281 -0
- package/dist/mcp/__tests__/server.test.js +140 -0
- package/dist/mcp/__tests__/write-tools.test.js +257 -0
- package/dist/mcp/formatters.js +202 -0
- package/dist/mcp/server.js +117 -0
- package/dist/mcp/tools/read-tools.js +208 -0
- package/dist/mcp/tools/shared.js +106 -0
- package/dist/mcp/tools/write-tools.js +252 -0
- package/dist/utils/__tests__/uri.test.js +21 -0
- package/dist/utils/uri.js +43 -0
- package/package.json +32 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.LspClient = void 0;
|
|
7
|
+
const node_events_1 = require("node:events");
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const uri_1 = require("../utils/uri");
|
|
11
|
+
class LspClient extends node_events_1.EventEmitter {
|
|
12
|
+
serverDef;
|
|
13
|
+
projectRoot;
|
|
14
|
+
logLevel;
|
|
15
|
+
process = null;
|
|
16
|
+
nextRequestId = 1;
|
|
17
|
+
ready = false;
|
|
18
|
+
pendingRequests = new Map();
|
|
19
|
+
buffer = Buffer.alloc(0);
|
|
20
|
+
contentLength = null;
|
|
21
|
+
exitExpected = false;
|
|
22
|
+
initializeResult = null;
|
|
23
|
+
forcedKillTimer = null;
|
|
24
|
+
constructor(serverDef, projectRoot, logLevel) {
|
|
25
|
+
super();
|
|
26
|
+
this.serverDef = serverDef;
|
|
27
|
+
this.projectRoot = projectRoot;
|
|
28
|
+
this.logLevel = logLevel;
|
|
29
|
+
}
|
|
30
|
+
async start() {
|
|
31
|
+
if (this.process) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.process = (0, node_child_process_1.spawn)(this.serverDef.cmd, this.serverDef.args, {
|
|
35
|
+
cwd: this.projectRoot,
|
|
36
|
+
env: { ...process.env },
|
|
37
|
+
stdio: 'pipe'
|
|
38
|
+
});
|
|
39
|
+
this.process.stdout.on('data', (chunk) => {
|
|
40
|
+
this.handleData(chunk);
|
|
41
|
+
});
|
|
42
|
+
this.process.on('error', (error) => {
|
|
43
|
+
this.log('error', 'lsp_process_error', { error: error.message });
|
|
44
|
+
this.emit('error', error);
|
|
45
|
+
});
|
|
46
|
+
this.process.on('exit', (code, signal) => {
|
|
47
|
+
this.handleExit(code, signal);
|
|
48
|
+
});
|
|
49
|
+
this.log('info', 'lsp_starting', { language: this.serverDef.cmd });
|
|
50
|
+
const initializeResult = await this.request('initialize', {
|
|
51
|
+
processId: process.pid,
|
|
52
|
+
clientInfo: { name: 'lsp-mcp', version: '0.1.0' },
|
|
53
|
+
rootUri: (0, uri_1.pathToUri)(this.projectRoot),
|
|
54
|
+
workspaceFolders: [
|
|
55
|
+
{
|
|
56
|
+
uri: (0, uri_1.pathToUri)(this.projectRoot),
|
|
57
|
+
name: node_path_1.default.basename(this.projectRoot)
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
capabilities: {}
|
|
61
|
+
}, 30000);
|
|
62
|
+
this.initializeResult = initializeResult;
|
|
63
|
+
this.notify('initialized', {});
|
|
64
|
+
this.ready = true;
|
|
65
|
+
this.log('info', 'lsp_ready', { language: this.serverDef.cmd });
|
|
66
|
+
}
|
|
67
|
+
isReady() {
|
|
68
|
+
return this.ready;
|
|
69
|
+
}
|
|
70
|
+
async request(method, params, timeout) {
|
|
71
|
+
const id = this.nextRequestId;
|
|
72
|
+
this.nextRequestId += 1;
|
|
73
|
+
const message = {
|
|
74
|
+
jsonrpc: '2.0',
|
|
75
|
+
id,
|
|
76
|
+
method,
|
|
77
|
+
params
|
|
78
|
+
};
|
|
79
|
+
return await new Promise((resolve, reject) => {
|
|
80
|
+
const timeoutHandle = setTimeout(() => {
|
|
81
|
+
this.pendingRequests.delete(id);
|
|
82
|
+
reject(new Error(`LSP request timed out: ${method}`));
|
|
83
|
+
}, timeout);
|
|
84
|
+
this.pendingRequests.set(id, {
|
|
85
|
+
resolve: (value) => resolve(value),
|
|
86
|
+
reject,
|
|
87
|
+
timeoutHandle
|
|
88
|
+
});
|
|
89
|
+
this.sendMessage(message);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
notify(method, params) {
|
|
93
|
+
const message = {
|
|
94
|
+
jsonrpc: '2.0',
|
|
95
|
+
method,
|
|
96
|
+
params
|
|
97
|
+
};
|
|
98
|
+
this.sendMessage(message);
|
|
99
|
+
}
|
|
100
|
+
async shutdown() {
|
|
101
|
+
this.exitExpected = true;
|
|
102
|
+
if (!this.process) {
|
|
103
|
+
this.ready = false;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await this.request('shutdown', {}, 5000);
|
|
107
|
+
this.notify('exit', {});
|
|
108
|
+
this.ready = false;
|
|
109
|
+
const currentProcess = this.process;
|
|
110
|
+
this.forcedKillTimer = setTimeout(() => {
|
|
111
|
+
currentProcess.kill('SIGKILL');
|
|
112
|
+
}, 5000);
|
|
113
|
+
}
|
|
114
|
+
getCapabilities() {
|
|
115
|
+
return this.initializeResult?.capabilities ?? null;
|
|
116
|
+
}
|
|
117
|
+
sendMessage(message) {
|
|
118
|
+
if (!this.process) {
|
|
119
|
+
throw new Error('LSP process is not running');
|
|
120
|
+
}
|
|
121
|
+
const body = JSON.stringify(message);
|
|
122
|
+
const content = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
|
|
123
|
+
this.process.stdin.write(content);
|
|
124
|
+
}
|
|
125
|
+
handleData(chunk) {
|
|
126
|
+
const incoming = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
|
127
|
+
this.buffer = Buffer.concat([this.buffer, incoming]);
|
|
128
|
+
while (true) {
|
|
129
|
+
if (this.contentLength === null) {
|
|
130
|
+
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
131
|
+
if (headerEnd === -1) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const header = this.buffer.subarray(0, headerEnd).toString('utf8');
|
|
135
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
136
|
+
const contentLengthLine = header
|
|
137
|
+
.split('\r\n')
|
|
138
|
+
.find((line) => line.toLowerCase().startsWith('content-length:'));
|
|
139
|
+
if (!contentLengthLine) {
|
|
140
|
+
const error = new Error('Missing Content-Length header');
|
|
141
|
+
this.emit('error', error);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const value = Number.parseInt(contentLengthLine.slice('Content-Length:'.length).trim(), 10);
|
|
145
|
+
this.contentLength = value;
|
|
146
|
+
}
|
|
147
|
+
if (this.buffer.byteLength < this.contentLength) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const messageBody = this.buffer.subarray(0, this.contentLength).toString('utf8');
|
|
151
|
+
this.buffer = this.buffer.subarray(this.contentLength);
|
|
152
|
+
this.contentLength = null;
|
|
153
|
+
this.handleMessage(JSON.parse(messageBody));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
handleMessage(message) {
|
|
157
|
+
if (!message || typeof message !== 'object') {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if ('method' in message) {
|
|
161
|
+
this.emit('notification', message.method, message.params);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if ('id' in message && 'error' in message && message.id !== null) {
|
|
165
|
+
const pending = this.pendingRequests.get(message.id);
|
|
166
|
+
if (!pending) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
clearTimeout(pending.timeoutHandle);
|
|
170
|
+
this.pendingRequests.delete(message.id);
|
|
171
|
+
pending.reject(new Error(message.error.message));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if ('id' in message && 'result' in message) {
|
|
175
|
+
const pending = this.pendingRequests.get(message.id);
|
|
176
|
+
if (!pending) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
clearTimeout(pending.timeoutHandle);
|
|
180
|
+
this.pendingRequests.delete(message.id);
|
|
181
|
+
pending.resolve(message.result);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
handleExit(code, signal) {
|
|
185
|
+
if (this.forcedKillTimer) {
|
|
186
|
+
clearTimeout(this.forcedKillTimer);
|
|
187
|
+
this.forcedKillTimer = null;
|
|
188
|
+
}
|
|
189
|
+
this.rejectAllPending(new Error(`LSP server exited (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`));
|
|
190
|
+
this.ready = false;
|
|
191
|
+
this.process = null;
|
|
192
|
+
if (!this.exitExpected) {
|
|
193
|
+
const error = new Error(`LSP server exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`);
|
|
194
|
+
this.log('error', 'lsp_crash', { error: error.message, language: this.serverDef.cmd });
|
|
195
|
+
this.emit('crash', error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
rejectAllPending(error) {
|
|
199
|
+
for (const [id, pending] of this.pendingRequests.entries()) {
|
|
200
|
+
clearTimeout(pending.timeoutHandle);
|
|
201
|
+
pending.reject(error);
|
|
202
|
+
this.pendingRequests.delete(id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
log(level, event, extra) {
|
|
206
|
+
if (!shouldLog(this.logLevel, level)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const payload = {
|
|
210
|
+
timestamp: new Date().toISOString(),
|
|
211
|
+
level,
|
|
212
|
+
event,
|
|
213
|
+
...extra
|
|
214
|
+
};
|
|
215
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
exports.LspClient = LspClient;
|
|
219
|
+
function shouldLog(configuredLevel, messageLevel) {
|
|
220
|
+
const order = {
|
|
221
|
+
error: 0,
|
|
222
|
+
info: 1,
|
|
223
|
+
debug: 2
|
|
224
|
+
};
|
|
225
|
+
return order[messageLevel] <= order[configuredLevel];
|
|
226
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const formatters_1 = require("../formatters");
|
|
4
|
+
describe('mcp formatters', () => {
|
|
5
|
+
it('formats hover results as markdown summary plus code block', () => {
|
|
6
|
+
expect((0, formatters_1.formatHover)({
|
|
7
|
+
contents: {
|
|
8
|
+
kind: 'markdown',
|
|
9
|
+
value: '```ts\ntype Foo = string\n```\n\nFoo description'
|
|
10
|
+
}
|
|
11
|
+
})).toBe('**type Foo = string** — Foo description\n\n```ts\ntype Foo = string\n```');
|
|
12
|
+
});
|
|
13
|
+
it('formats definition locations with file coordinates', () => {
|
|
14
|
+
expect((0, formatters_1.formatDefinition)([
|
|
15
|
+
{
|
|
16
|
+
uri: 'file:///workspace/src/index.ts',
|
|
17
|
+
range: {
|
|
18
|
+
start: { line: 41, character: 4 },
|
|
19
|
+
end: { line: 41, character: 7 }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
])).toBe('Found 1 definition: `/workspace/src/index.ts:42:5`');
|
|
23
|
+
});
|
|
24
|
+
it('formats references as a bulleted list', () => {
|
|
25
|
+
expect((0, formatters_1.formatReferences)([
|
|
26
|
+
{
|
|
27
|
+
uri: 'file:///workspace/src/index.ts',
|
|
28
|
+
range: {
|
|
29
|
+
start: { line: 0, character: 0 },
|
|
30
|
+
end: { line: 0, character: 1 }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
uri: 'file:///workspace/src/lib.ts',
|
|
35
|
+
range: {
|
|
36
|
+
start: { line: 2, character: 3 },
|
|
37
|
+
end: { line: 2, character: 4 }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
])).toBe('Found 2 references:\n- `/workspace/src/index.ts:1:1`\n- `/workspace/src/lib.ts:3:4`');
|
|
41
|
+
});
|
|
42
|
+
it('formats symbols with kind icons', () => {
|
|
43
|
+
expect((0, formatters_1.formatSymbols)([
|
|
44
|
+
{
|
|
45
|
+
name: 'UserService',
|
|
46
|
+
kind: 5,
|
|
47
|
+
location: {
|
|
48
|
+
uri: 'file:///workspace/src/user-service.ts',
|
|
49
|
+
range: {
|
|
50
|
+
start: { line: 4, character: 0 },
|
|
51
|
+
end: { line: 10, character: 0 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'login',
|
|
57
|
+
kind: 12,
|
|
58
|
+
location: {
|
|
59
|
+
uri: 'file:///workspace/src/user-service.ts',
|
|
60
|
+
range: {
|
|
61
|
+
start: { line: 6, character: 2 },
|
|
62
|
+
end: { line: 8, character: 0 }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
])).toContain('- 📦 `UserService` — /workspace/src/user-service.ts:5:1');
|
|
67
|
+
});
|
|
68
|
+
it('formats diagnostics grouped by severity with errors first', () => {
|
|
69
|
+
expect((0, formatters_1.formatDiagnostics)([
|
|
70
|
+
{
|
|
71
|
+
message: 'Cannot find name Foo',
|
|
72
|
+
severity: 1,
|
|
73
|
+
source: 'ts',
|
|
74
|
+
range: {
|
|
75
|
+
start: { line: 1, character: 0 },
|
|
76
|
+
end: { line: 1, character: 3 }
|
|
77
|
+
},
|
|
78
|
+
uri: 'file:///workspace/src/index.ts'
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
message: 'Unused variable',
|
|
82
|
+
severity: 2,
|
|
83
|
+
source: 'ts',
|
|
84
|
+
range: {
|
|
85
|
+
start: { line: 3, character: 2 },
|
|
86
|
+
end: { line: 3, character: 5 }
|
|
87
|
+
},
|
|
88
|
+
uri: 'file:///workspace/src/index.ts'
|
|
89
|
+
}
|
|
90
|
+
], 'workspace')).toBe([
|
|
91
|
+
'Workspace diagnostics: 2 issue(s)',
|
|
92
|
+
'',
|
|
93
|
+
'### Errors',
|
|
94
|
+
'- `/workspace/src/index.ts:2:1` ts: Cannot find name Foo',
|
|
95
|
+
'',
|
|
96
|
+
'### Warnings',
|
|
97
|
+
'- `/workspace/src/index.ts:4:3` ts: Unused variable'
|
|
98
|
+
].join('\n'));
|
|
99
|
+
});
|
|
100
|
+
it('formats completion items grouped by kind and limited to 50', () => {
|
|
101
|
+
const items = Array.from({ length: 55 }, (_, index) => ({
|
|
102
|
+
label: `item-${index}`,
|
|
103
|
+
kind: index < 30 ? 3 : 2,
|
|
104
|
+
detail: index === 0 ? 'string' : undefined
|
|
105
|
+
}));
|
|
106
|
+
const text = (0, formatters_1.formatCompletion)(items);
|
|
107
|
+
expect(text).toContain('Showing 50 of 55 completion item(s)');
|
|
108
|
+
expect(text).toContain('### Functions');
|
|
109
|
+
expect(text).toContain('- `item-0` — string');
|
|
110
|
+
expect(text).toContain('### Methods');
|
|
111
|
+
expect(text).not.toContain('item-54');
|
|
112
|
+
});
|
|
113
|
+
it('formats health rows as a markdown table', () => {
|
|
114
|
+
expect((0, formatters_1.formatHealth)([
|
|
115
|
+
{ language: 'typescript', status: 'ready' },
|
|
116
|
+
{ language: 'python', status: 'error', error: 'spawn failed' }
|
|
117
|
+
])).toBe([
|
|
118
|
+
'| Language | Status | Error |',
|
|
119
|
+
'| --- | --- | --- |',
|
|
120
|
+
'| typescript | ready | |',
|
|
121
|
+
'| python | error | spawn failed |'
|
|
122
|
+
].join('\n'));
|
|
123
|
+
});
|
|
124
|
+
it('formats errors with raw payload passthrough', () => {
|
|
125
|
+
expect((0, formatters_1.formatError)(new Error('boom'))).toEqual({
|
|
126
|
+
error: true,
|
|
127
|
+
text: 'boom',
|
|
128
|
+
raw: expect.any(Error)
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
it('returns no result for empty formatter inputs', () => {
|
|
132
|
+
expect((0, formatters_1.formatHover)(null)).toBe('No result');
|
|
133
|
+
expect((0, formatters_1.formatDefinition)(null)).toBe('No result');
|
|
134
|
+
expect((0, formatters_1.formatReferences)([])).toBe('No result');
|
|
135
|
+
expect((0, formatters_1.formatSymbols)(null)).toBe('No result');
|
|
136
|
+
expect((0, formatters_1.formatDiagnostics)([], 'file')).toBe('No result');
|
|
137
|
+
expect((0, formatters_1.formatCompletion)(null)).toBe('No result');
|
|
138
|
+
expect((0, formatters_1.formatHealth)([])).toBe('No result');
|
|
139
|
+
});
|
|
140
|
+
it('formats multi-definition and string hover fallbacks', () => {
|
|
141
|
+
expect((0, formatters_1.formatHover)({ contents: 'Plain hover text' })).toBe('Plain hover text');
|
|
142
|
+
expect((0, formatters_1.formatDefinition)([
|
|
143
|
+
{
|
|
144
|
+
uri: 'file:///workspace/src/a.ts',
|
|
145
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
uri: 'file:///workspace/src/b.ts',
|
|
149
|
+
range: { start: { line: 1, character: 1 }, end: { line: 1, character: 2 } }
|
|
150
|
+
}
|
|
151
|
+
])).toContain('Found definitions:');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const promises_1 = require("node:fs/promises");
|
|
4
|
+
const read_tools_1 = require("../tools/read-tools");
|
|
5
|
+
const vscode_languageserver_protocol_1 = require("vscode-languageserver-protocol");
|
|
6
|
+
jest.mock('node:fs/promises', () => ({
|
|
7
|
+
readFile: jest.fn(),
|
|
8
|
+
stat: jest.fn()
|
|
9
|
+
}));
|
|
10
|
+
class FakeRegistrar {
|
|
11
|
+
tools = new Map();
|
|
12
|
+
registerTool(name, config, handler) {
|
|
13
|
+
this.tools.set(name, { description: config.description, handler });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
describe('registerReadTools', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
promises_1.readFile.mockResolvedValue('const foo = 1;');
|
|
20
|
+
promises_1.stat.mockResolvedValue({ isDirectory: () => true });
|
|
21
|
+
});
|
|
22
|
+
it('initializes LSP with a valid root and reports health', async () => {
|
|
23
|
+
const registrar = new FakeRegistrar();
|
|
24
|
+
const initializeManager = jest.fn().mockResolvedValue({
|
|
25
|
+
root: '/workspace',
|
|
26
|
+
health: [
|
|
27
|
+
{ language: 'typescript', status: 'ready' },
|
|
28
|
+
{ language: 'python', status: 'error', error: 'missing pylsp' }
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
|
|
32
|
+
await expect(getHandler(registrar, 'lsp_init')({ root: '/workspace' })).resolves.toEqual({
|
|
33
|
+
content: [{ type: 'text', text: 'Initialized LSP for /workspace. Detected languages: Typescript, Python. LSP servers: 1 started, 1 errors.' }],
|
|
34
|
+
text: 'Initialized LSP for /workspace. Detected languages: Typescript, Python. LSP servers: 1 started, 1 errors.',
|
|
35
|
+
raw: {
|
|
36
|
+
root: '/workspace',
|
|
37
|
+
languages: ['Typescript', 'Python'],
|
|
38
|
+
health: [
|
|
39
|
+
{ language: 'typescript', status: 'ready' },
|
|
40
|
+
{ language: 'python', status: 'error', error: 'missing pylsp' }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
expect(initializeManager).toHaveBeenCalledWith('/workspace', undefined);
|
|
45
|
+
});
|
|
46
|
+
it('rejects invalid lsp_init roots clearly', async () => {
|
|
47
|
+
const registrar = new FakeRegistrar();
|
|
48
|
+
const initializeManager = jest.fn();
|
|
49
|
+
promises_1.stat.mockRejectedValueOnce(new Error('ENOENT'));
|
|
50
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
|
|
51
|
+
await expect(getHandler(registrar, 'lsp_init')({ root: '/missing/project' })).resolves.toEqual({
|
|
52
|
+
content: [{ type: 'text', text: 'Project root does not exist: /missing/project' }],
|
|
53
|
+
error: true,
|
|
54
|
+
raw: null
|
|
55
|
+
});
|
|
56
|
+
expect(initializeManager).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it('rejects missing, relative, and non-directory lsp_init roots', async () => {
|
|
59
|
+
const registrar = new FakeRegistrar();
|
|
60
|
+
const initializeManager = jest.fn();
|
|
61
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
|
|
62
|
+
await expect(getHandler(registrar, 'lsp_init')({})).resolves.toEqual({
|
|
63
|
+
content: [{ type: 'text', text: 'Project root is required. Provide lsp_init({ root: \'/absolute/path\' }).' }],
|
|
64
|
+
error: true,
|
|
65
|
+
raw: null
|
|
66
|
+
});
|
|
67
|
+
await expect(getHandler(registrar, 'lsp_init')({ root: 'relative/path' })).resolves.toEqual({
|
|
68
|
+
content: [{ type: 'text', text: 'Project root must be an absolute path: relative/path' }],
|
|
69
|
+
error: true,
|
|
70
|
+
raw: null
|
|
71
|
+
});
|
|
72
|
+
promises_1.stat.mockResolvedValueOnce({ isDirectory: () => false });
|
|
73
|
+
await expect(getHandler(registrar, 'lsp_init')({ root: '/workspace/file.ts' })).resolves.toEqual({
|
|
74
|
+
content: [{ type: 'text', text: 'Project root is not a directory: /workspace/file.ts' }],
|
|
75
|
+
error: true,
|
|
76
|
+
raw: null
|
|
77
|
+
});
|
|
78
|
+
expect(initializeManager).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
it('maps lsp_init startup failures into tool errors', async () => {
|
|
81
|
+
const registrar = new FakeRegistrar();
|
|
82
|
+
const initializeManager = jest.fn().mockRejectedValue(new Error('Lifecycle start timed out'));
|
|
83
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager });
|
|
84
|
+
await expect(getHandler(registrar, 'lsp_init')({ root: '/workspace' })).resolves.toEqual({
|
|
85
|
+
content: [{ type: 'text', text: 'Operation timed out after 30s — try a more specific query or check the LSP server health' }],
|
|
86
|
+
error: true,
|
|
87
|
+
raw: null
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('sends didOpen once and returns formatted hover results', async () => {
|
|
91
|
+
const registrar = new FakeRegistrar();
|
|
92
|
+
const client = createClient({ contents: 'hover docs' });
|
|
93
|
+
const lifecycle = createLifecycle({ fileClient: client });
|
|
94
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
95
|
+
const hover = await getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 2, character: 4 });
|
|
96
|
+
await getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 2, character: 4 });
|
|
97
|
+
expect(promises_1.readFile).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(client.notify).toHaveBeenCalledWith('textDocument/didOpen', expect.objectContaining({
|
|
99
|
+
textDocument: expect.objectContaining({ uri: 'file:///workspace/src/index.ts', text: 'const foo = 1;' })
|
|
100
|
+
}));
|
|
101
|
+
expect(client.request).toHaveBeenCalledWith('textDocument/hover', {
|
|
102
|
+
textDocument: { uri: 'file:///workspace/src/index.ts' },
|
|
103
|
+
position: { line: 2, character: 4 }
|
|
104
|
+
}, 5000);
|
|
105
|
+
expect(hover).toEqual({
|
|
106
|
+
content: [{ type: 'text', text: 'hover docs' }],
|
|
107
|
+
raw: { contents: 'hover docs' }
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
it('formats definitions and converts URIs back to paths', async () => {
|
|
111
|
+
const registrar = new FakeRegistrar();
|
|
112
|
+
const lifecycle = createLifecycle({
|
|
113
|
+
fileClient: createClient([
|
|
114
|
+
{
|
|
115
|
+
uri: 'file:///workspace/src/defs.ts',
|
|
116
|
+
range: { start: { line: 3, character: 1 }, end: { line: 3, character: 2 } }
|
|
117
|
+
}
|
|
118
|
+
])
|
|
119
|
+
});
|
|
120
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
121
|
+
await expect(getHandler(registrar, 'lsp_definition')({ file: '/workspace/src/index.ts', line: 1, character: 1 })).resolves.toEqual({
|
|
122
|
+
content: [{ type: 'text', text: 'Found 1 definition: `/workspace/src/defs.ts:4:2`' }],
|
|
123
|
+
raw: [
|
|
124
|
+
{
|
|
125
|
+
path: '/workspace/src/defs.ts',
|
|
126
|
+
range: { start: { line: 3, character: 1 }, end: { line: 3, character: 2 } }
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
it('uses the declaration flag when requesting references', async () => {
|
|
132
|
+
const registrar = new FakeRegistrar();
|
|
133
|
+
const client = createClient([]);
|
|
134
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
|
|
135
|
+
await getHandler(registrar, 'lsp_references')({ file: '/workspace/src/index.ts', line: 0, character: 0, includeDeclaration: true });
|
|
136
|
+
expect(client.request).toHaveBeenCalledWith('textDocument/references', {
|
|
137
|
+
textDocument: { uri: 'file:///workspace/src/index.ts' },
|
|
138
|
+
position: { line: 0, character: 0 },
|
|
139
|
+
context: { includeDeclaration: true }
|
|
140
|
+
}, 15000);
|
|
141
|
+
});
|
|
142
|
+
it('merges workspace symbols across ready clients', async () => {
|
|
143
|
+
const registrar = new FakeRegistrar();
|
|
144
|
+
const firstClient = createClient([{ name: 'UserService', kind: 5, location: { uri: 'file:///workspace/src/user.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } } } }]);
|
|
145
|
+
const secondClient = createClient([{ name: 'login', kind: 12, location: { uri: 'file:///workspace/src/auth.ts', range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } } } }]);
|
|
146
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ workspaceClients: [firstClient, secondClient] }), { initializeManager: jest.fn() });
|
|
147
|
+
const result = await getHandler(registrar, 'lsp_workspace_symbols')({ query: 'log' });
|
|
148
|
+
expect(firstClient.request).toHaveBeenCalledWith('workspace/symbol', { query: 'log' }, 30000);
|
|
149
|
+
expect(secondClient.request).toHaveBeenCalledWith('workspace/symbol', { query: 'log' }, 30000);
|
|
150
|
+
expect(result).toEqual({
|
|
151
|
+
content: [{ type: 'text', text: expect.stringContaining('UserService') }],
|
|
152
|
+
raw: [
|
|
153
|
+
{ name: 'UserService', kind: 5, path: '/workspace/src/user.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } } },
|
|
154
|
+
{ name: 'login', kind: 12, path: '/workspace/src/auth.ts', range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } } }
|
|
155
|
+
]
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
it('returns cached file diagnostics and aggregates workspace diagnostics', async () => {
|
|
159
|
+
const registrar = new FakeRegistrar();
|
|
160
|
+
const diagnostics = [{ uri: 'file:///workspace/src/index.ts', message: 'Boom', severity: vscode_languageserver_protocol_1.DiagnosticSeverity.Error, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }];
|
|
161
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ diagnostics, workspaceClients: [createClient([])] }), { initializeManager: jest.fn() });
|
|
162
|
+
await expect(getHandler(registrar, 'lsp_diagnostics')({ file: '/workspace/src/index.ts' })).resolves.toEqual({
|
|
163
|
+
content: [{ type: 'text', text: expect.stringContaining('File diagnostics: 1 issue(s)') }],
|
|
164
|
+
raw: diagnostics
|
|
165
|
+
});
|
|
166
|
+
await expect(getHandler(registrar, 'lsp_diagnostics')({ scope: 'workspace' })).resolves.toEqual({
|
|
167
|
+
content: [{ type: 'text', text: expect.stringContaining('Workspace diagnostics: 1 issue(s)') }],
|
|
168
|
+
raw: diagnostics
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
it('returns health instantly without LSP requests', async () => {
|
|
172
|
+
const registrar = new FakeRegistrar();
|
|
173
|
+
const lifecycle = createLifecycle({ health: [{ language: 'typescript', status: 'ready' }] });
|
|
174
|
+
(0, read_tools_1.registerReadTools)(registrar, lifecycle, { initializeManager: jest.fn() });
|
|
175
|
+
await expect(getHandler(registrar, 'lsp_health')({})).resolves.toEqual({
|
|
176
|
+
content: [{ type: 'text', text: '| Language | Status | Error |\n| --- | --- | --- |\n| typescript | ready | |' }],
|
|
177
|
+
raw: [{ language: 'typescript', status: 'ready' }]
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
it('supports document symbols, completion lists, and signature help fallbacks', async () => {
|
|
181
|
+
const registrar = new FakeRegistrar();
|
|
182
|
+
const client = createClient({ items: [{ label: 'x', kind: 3 }] });
|
|
183
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
|
|
184
|
+
await expect(getHandler(registrar, 'lsp_completion')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
185
|
+
content: [{ type: 'text', text: 'Showing 1 of 1 completion item(s)\n\n### Functions\n- `x`' }],
|
|
186
|
+
raw: [{ label: 'x', kind: 3 }]
|
|
187
|
+
});
|
|
188
|
+
client.request.mockResolvedValueOnce([{ name: 'DocSymbol', kind: 5, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]);
|
|
189
|
+
await expect(getHandler(registrar, 'lsp_document_symbols')({ file: '/workspace/src/index.ts' })).resolves.toEqual({
|
|
190
|
+
content: [{ type: 'text', text: '- 📦 `DocSymbol`' }],
|
|
191
|
+
raw: [{ name: 'DocSymbol', kind: 5, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]
|
|
192
|
+
});
|
|
193
|
+
client.request.mockResolvedValueOnce(null);
|
|
194
|
+
await expect(getHandler(registrar, 'lsp_signature_help')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
195
|
+
content: [{ type: 'text', text: 'No result' }],
|
|
196
|
+
raw: null
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
it('supports type and implementation lookups plus empty completion results', async () => {
|
|
200
|
+
const registrar = new FakeRegistrar();
|
|
201
|
+
const client = createClient({
|
|
202
|
+
uri: 'file:///workspace/src/types.ts',
|
|
203
|
+
range: { start: { line: 1, character: 2 }, end: { line: 1, character: 6 } }
|
|
204
|
+
});
|
|
205
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
|
|
206
|
+
await expect(getHandler(registrar, 'lsp_type_definition')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
207
|
+
content: [{ type: 'text', text: 'Found 1 definition: `/workspace/src/types.ts:2:3`' }],
|
|
208
|
+
raw: [{ path: '/workspace/src/types.ts', range: { start: { line: 1, character: 2 }, end: { line: 1, character: 6 } } }]
|
|
209
|
+
});
|
|
210
|
+
client.request.mockResolvedValueOnce(null);
|
|
211
|
+
await expect(getHandler(registrar, 'lsp_implementation')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
212
|
+
content: [{ type: 'text', text: 'No result' }],
|
|
213
|
+
raw: null
|
|
214
|
+
});
|
|
215
|
+
client.request.mockResolvedValueOnce(null);
|
|
216
|
+
await expect(getHandler(registrar, 'lsp_completion')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
217
|
+
content: [{ type: 'text', text: 'No result' }],
|
|
218
|
+
raw: null
|
|
219
|
+
});
|
|
220
|
+
client.request.mockResolvedValueOnce({ signatures: [{ label: 'fn(x: string)' }] });
|
|
221
|
+
await expect(getHandler(registrar, 'lsp_signature_help')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
222
|
+
content: [{ type: 'text', text: JSON.stringify({ signatures: [{ label: 'fn(x: string)' }] }, null, 2) }],
|
|
223
|
+
raw: { signatures: [{ label: 'fn(x: string)' }] }
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
it('turns LSP timeouts into retry guidance', async () => {
|
|
227
|
+
const registrar = new FakeRegistrar();
|
|
228
|
+
const client = createClient(new Error('LSP request timed out: textDocument/hover'));
|
|
229
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
|
|
230
|
+
await expect(getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
231
|
+
content: [{ type: 'text', text: 'Operation timed out after 5s — try a more specific query or check the LSP server health' }],
|
|
232
|
+
error: true,
|
|
233
|
+
raw: null
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
it('returns a no-server error when no language server matches the file', async () => {
|
|
237
|
+
const registrar = new FakeRegistrar();
|
|
238
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: null }), { initializeManager: jest.fn() });
|
|
239
|
+
await expect(getHandler(registrar, 'lsp_hover')({ file: '/workspace/README.md', line: 0, character: 0 })).resolves.toEqual({
|
|
240
|
+
content: [{ type: 'text', text: 'No language server available for .md files. Run lsp_health for details.' }],
|
|
241
|
+
error: true,
|
|
242
|
+
raw: null
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
it('returns restart guidance when the LSP crashed', async () => {
|
|
246
|
+
const registrar = new FakeRegistrar();
|
|
247
|
+
const client = createClient(new Error('LSP server exited unexpectedly (code: 1, signal: null)'));
|
|
248
|
+
(0, read_tools_1.registerReadTools)(registrar, createLifecycle({ fileClient: client }), { initializeManager: jest.fn() });
|
|
249
|
+
await expect(getHandler(registrar, 'lsp_hover')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
250
|
+
content: [{ type: 'text', text: 'Der Language Server ist neu gestartet, bitte versuche es erneut.' }],
|
|
251
|
+
error: true,
|
|
252
|
+
raw: null
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
function getHandler(registrar, name) {
|
|
257
|
+
const tool = registrar.tools.get(name);
|
|
258
|
+
if (!tool) {
|
|
259
|
+
throw new Error(`Missing tool ${name}`);
|
|
260
|
+
}
|
|
261
|
+
return tool.handler;
|
|
262
|
+
}
|
|
263
|
+
function createLifecycle(options) {
|
|
264
|
+
return {
|
|
265
|
+
getClientForFile: jest.fn((_) => options.fileClient ?? null),
|
|
266
|
+
getReadyClients: jest.fn((_) => options.workspaceClients ?? []),
|
|
267
|
+
getFileDiagnostics: jest.fn((_) => (options.diagnostics ?? []).filter((diagnostic) => diagnostic.uri === 'file:///workspace/src/index.ts')),
|
|
268
|
+
getWorkspaceDiagnostics: jest.fn((_) => options.diagnostics ?? []),
|
|
269
|
+
getHealth: jest.fn(() => options.health ?? []),
|
|
270
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function createClient(result) {
|
|
274
|
+
return {
|
|
275
|
+
request: result instanceof Error
|
|
276
|
+
? jest.fn().mockRejectedValue(result)
|
|
277
|
+
: jest.fn().mockResolvedValue(result),
|
|
278
|
+
notify: jest.fn(),
|
|
279
|
+
getCapabilities: jest.fn(() => ({ renameProvider: true }))
|
|
280
|
+
};
|
|
281
|
+
}
|