@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,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mockSetRequestHandler = jest.fn();
|
|
4
|
+
const mockConnect = jest.fn().mockResolvedValue(undefined);
|
|
5
|
+
jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
|
6
|
+
McpServer: jest.fn().mockImplementation(() => ({
|
|
7
|
+
connect: mockConnect,
|
|
8
|
+
server: {
|
|
9
|
+
setRequestHandler: mockSetRequestHandler
|
|
10
|
+
}
|
|
11
|
+
}))
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
|
14
|
+
StdioServerTransport: jest.fn().mockImplementation(() => ({ kind: 'stdio' }))
|
|
15
|
+
}));
|
|
16
|
+
jest.mock('@modelcontextprotocol/sdk/server/zod-compat.js', () => ({
|
|
17
|
+
normalizeObjectSchema: jest.fn().mockReturnValue(null)
|
|
18
|
+
}));
|
|
19
|
+
jest.mock('@modelcontextprotocol/sdk/server/zod-json-schema-compat.js', () => ({
|
|
20
|
+
toJsonSchemaCompat: jest.fn().mockReturnValue({})
|
|
21
|
+
}));
|
|
22
|
+
jest.mock('../tools/read-tools', () => ({ registerReadTools: jest.fn() }));
|
|
23
|
+
jest.mock('../tools/write-tools', () => ({ registerWriteTools: jest.fn() }));
|
|
24
|
+
const server_1 = require("../server");
|
|
25
|
+
function getHandler(schema) {
|
|
26
|
+
const call = mockSetRequestHandler.mock.calls.find(([s]) => s === schema);
|
|
27
|
+
if (!call)
|
|
28
|
+
throw new Error(`No handler registered for schema`);
|
|
29
|
+
return call[1];
|
|
30
|
+
}
|
|
31
|
+
describe('McpServer', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
it('registers request handlers and connects on start', async () => {
|
|
36
|
+
const server = new server_1.McpServer('info');
|
|
37
|
+
await server.start();
|
|
38
|
+
expect(mockSetRequestHandler).toHaveBeenCalledTimes(2);
|
|
39
|
+
expect(mockConnect).toHaveBeenCalledWith({ kind: 'stdio' });
|
|
40
|
+
});
|
|
41
|
+
it('lists all registered tools regardless of init state', async () => {
|
|
42
|
+
const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
|
|
43
|
+
const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
|
|
44
|
+
readTools.mockImplementationOnce((registrar) => {
|
|
45
|
+
registrar.registerTool('lsp_init', { description: 'init' }, async () => ({ content: [{ type: 'text', text: 'ok' }], raw: null }));
|
|
46
|
+
registrar.registerTool('lsp_hover', { description: 'hover' }, async () => ({ content: [{ type: 'text', text: 'hover' }], raw: null }));
|
|
47
|
+
});
|
|
48
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
49
|
+
const { ListToolsRequestSchema } = jest.requireActual('@modelcontextprotocol/sdk/types.js');
|
|
50
|
+
const server = new server_1.McpServer('info');
|
|
51
|
+
await server.start();
|
|
52
|
+
const listHandler = getHandler(ListToolsRequestSchema);
|
|
53
|
+
const result = await listHandler({});
|
|
54
|
+
expect(result.tools.map((t) => t.name)).toContain('lsp_init');
|
|
55
|
+
expect(result.tools.map((t) => t.name)).toContain('lsp_hover');
|
|
56
|
+
});
|
|
57
|
+
it('lists all tools after initialization too', async () => {
|
|
58
|
+
const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
|
|
59
|
+
const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
|
|
60
|
+
readTools.mockImplementationOnce((registrar) => {
|
|
61
|
+
registrar.registerTool('lsp_init', { description: 'init' }, async () => ({ content: [{ type: 'text', text: 'ok' }], raw: null }));
|
|
62
|
+
registrar.registerTool('lsp_hover', { description: 'hover' }, async () => ({ content: [{ type: 'text', text: 'hover' }], raw: null }));
|
|
63
|
+
});
|
|
64
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
65
|
+
const { ListToolsRequestSchema } = jest.requireActual('@modelcontextprotocol/sdk/types.js');
|
|
66
|
+
const server = new server_1.McpServer('info');
|
|
67
|
+
await server.start();
|
|
68
|
+
server.setManager({});
|
|
69
|
+
const listHandler = getHandler(ListToolsRequestSchema);
|
|
70
|
+
const result = await listHandler({});
|
|
71
|
+
expect(result.tools.map((t) => t.name)).toContain('lsp_init');
|
|
72
|
+
expect(result.tools.map((t) => t.name)).toContain('lsp_hover');
|
|
73
|
+
});
|
|
74
|
+
it('returns no-root error for non-init tools before lsp_init', async () => {
|
|
75
|
+
const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
|
|
76
|
+
const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
|
|
77
|
+
readTools.mockImplementationOnce((registrar) => {
|
|
78
|
+
registrar.registerTool('lsp_hover', { description: 'hover' }, async () => ({ content: [{ type: 'text', text: 'reachable' }], raw: null }));
|
|
79
|
+
});
|
|
80
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
81
|
+
const { CallToolRequestSchema } = jest.requireActual('@modelcontextprotocol/sdk/types.js');
|
|
82
|
+
const server = new server_1.McpServer('info');
|
|
83
|
+
await server.start();
|
|
84
|
+
const callHandler = getHandler(CallToolRequestSchema);
|
|
85
|
+
const result = await callHandler({ params: { name: 'lsp_hover', arguments: {} } });
|
|
86
|
+
expect(result.content[0].text).toMatch(/No project root set/);
|
|
87
|
+
});
|
|
88
|
+
it('re-initializes with new root when lsp_init called again', async () => {
|
|
89
|
+
const readTools = jest.requireMock('../tools/read-tools').registerReadTools;
|
|
90
|
+
const writeTools = jest.requireMock('../tools/write-tools').registerWriteTools;
|
|
91
|
+
readTools.mockImplementationOnce((registrar) => {
|
|
92
|
+
registrar.registerTool('lsp_init', { description: 'init' }, async () => ({ content: [{ type: 'text', text: 'ok' }], raw: null }));
|
|
93
|
+
});
|
|
94
|
+
writeTools.mockImplementationOnce(() => undefined);
|
|
95
|
+
const { CallToolRequestSchema } = jest.requireActual('@modelcontextprotocol/sdk/types.js');
|
|
96
|
+
const server = new server_1.McpServer('info');
|
|
97
|
+
await server.start();
|
|
98
|
+
server.setManager({});
|
|
99
|
+
const callHandler = getHandler(CallToolRequestSchema);
|
|
100
|
+
const result = await callHandler({ params: { name: 'lsp_init', arguments: { root: '/x' } } });
|
|
101
|
+
expect(result.content[0].text).toMatch(/Already initialized/);
|
|
102
|
+
});
|
|
103
|
+
it('shuts down the old manager before starting a new one', async () => {
|
|
104
|
+
const order = [];
|
|
105
|
+
const firstManager = {
|
|
106
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
107
|
+
shutdown: jest.fn().mockImplementation(async () => { order.push('shutdown-first'); }),
|
|
108
|
+
getHealth: jest.fn().mockReturnValue([{ language: 'typescript', status: 'ready' }])
|
|
109
|
+
};
|
|
110
|
+
const secondManager = {
|
|
111
|
+
start: jest.fn().mockImplementation(async () => { order.push('start-second'); }),
|
|
112
|
+
shutdown: jest.fn().mockResolvedValue(undefined),
|
|
113
|
+
getHealth: jest.fn().mockReturnValue([{ language: 'python', status: 'ready' }])
|
|
114
|
+
};
|
|
115
|
+
const factory = jest.fn()
|
|
116
|
+
.mockReturnValueOnce(firstManager)
|
|
117
|
+
.mockReturnValueOnce(secondManager);
|
|
118
|
+
const server = new server_1.McpServer('debug', factory);
|
|
119
|
+
await expect(server.initializeManager('/workspace-one')).resolves.toEqual({
|
|
120
|
+
root: '/workspace-one',
|
|
121
|
+
health: [{ language: 'typescript', status: 'ready' }]
|
|
122
|
+
});
|
|
123
|
+
await expect(server.initializeManager('/workspace-two')).resolves.toEqual({
|
|
124
|
+
root: '/workspace-two',
|
|
125
|
+
health: [{ language: 'python', status: 'ready' }]
|
|
126
|
+
});
|
|
127
|
+
expect(order).toEqual(['shutdown-first', 'start-second']);
|
|
128
|
+
});
|
|
129
|
+
it('tolerates shutdown with no active manager', async () => {
|
|
130
|
+
const server = new server_1.McpServer('info');
|
|
131
|
+
await expect(server.shutdown()).resolves.toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
it('shuts down active manager on server shutdown', async () => {
|
|
134
|
+
const manager = { shutdown: jest.fn().mockResolvedValue(undefined) };
|
|
135
|
+
const server = new server_1.McpServer('info');
|
|
136
|
+
server.setManager(manager);
|
|
137
|
+
await server.shutdown();
|
|
138
|
+
expect(manager.shutdown).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const promises_1 = require("node:fs/promises");
|
|
4
|
+
const shared_1 = require("../tools/shared");
|
|
5
|
+
const write_tools_1 = require("../tools/write-tools");
|
|
6
|
+
jest.mock('node:fs/promises', () => ({
|
|
7
|
+
access: jest.fn(),
|
|
8
|
+
readFile: jest.fn(),
|
|
9
|
+
writeFile: jest.fn()
|
|
10
|
+
}));
|
|
11
|
+
class FakeRegistrar {
|
|
12
|
+
tools = new Map();
|
|
13
|
+
registerTool(name, _config, handler) {
|
|
14
|
+
this.tools.set(name, { handler });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
describe('registerWriteTools', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
(0, shared_1.clearOpenedFiles)();
|
|
21
|
+
promises_1.access.mockResolvedValue(undefined);
|
|
22
|
+
promises_1.readFile.mockImplementation(async (filePath) => {
|
|
23
|
+
const pathText = String(filePath);
|
|
24
|
+
if (pathText.endsWith('.editorconfig')) {
|
|
25
|
+
return 'root = true\n[*]\nindent_size = 2\nindent_style = space\n';
|
|
26
|
+
}
|
|
27
|
+
return 'const foo = oldName\n';
|
|
28
|
+
});
|
|
29
|
+
promises_1.writeFile.mockResolvedValue(undefined);
|
|
30
|
+
});
|
|
31
|
+
it('renames symbols when the server supports rename and saves the changed file', async () => {
|
|
32
|
+
const registrar = new FakeRegistrar();
|
|
33
|
+
const client = createClient({
|
|
34
|
+
changes: {
|
|
35
|
+
'file:///workspace/src/index.ts': [{ range: { start: { line: 0, character: 12 }, end: { line: 0, character: 19 } }, newText: 'newName' }]
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
39
|
+
const result = await getHandler(registrar, 'lsp_rename')({ file: '/workspace/src/index.ts', line: 0, character: 12, newName: 'newName' });
|
|
40
|
+
expect(client.request).toHaveBeenCalledWith('textDocument/rename', {
|
|
41
|
+
textDocument: { uri: 'file:///workspace/src/index.ts' },
|
|
42
|
+
position: { line: 0, character: 12 },
|
|
43
|
+
newName: 'newName'
|
|
44
|
+
}, 15000);
|
|
45
|
+
expect(promises_1.writeFile).toHaveBeenCalledWith('/workspace/src/index.ts', 'const foo = newName\n', 'utf8');
|
|
46
|
+
expect(client.notify).toHaveBeenCalledWith('textDocument/didSave', { textDocument: { uri: 'file:///workspace/src/index.ts' } });
|
|
47
|
+
expect(result).toEqual({
|
|
48
|
+
content: [{ type: 'text', text: 'Applied workspace edit to 1 file(s)' }],
|
|
49
|
+
raw: { changedFiles: ['/workspace/src/index.ts'] }
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('returns an error when rename is unsupported', async () => {
|
|
53
|
+
const registrar = new FakeRegistrar();
|
|
54
|
+
const client = createClient(null, { renameProvider: false });
|
|
55
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
56
|
+
await expect(getHandler(registrar, 'lsp_rename')({ file: '/workspace/src/index.ts', line: 0, character: 0, newName: 'x' })).resolves.toEqual({
|
|
57
|
+
content: [{ type: 'text', text: 'Rename is not supported by the active language server.' }],
|
|
58
|
+
error: true,
|
|
59
|
+
raw: null
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
it('lists code actions without applying them', async () => {
|
|
63
|
+
const registrar = new FakeRegistrar();
|
|
64
|
+
const actions = [{ title: 'Fix import', kind: 'quickfix' }];
|
|
65
|
+
const client = createClient(actions);
|
|
66
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
67
|
+
await expect(getHandler(registrar, 'lsp_code_action')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
68
|
+
content: [{ type: 'text', text: 'Available code actions:\n- [0] Fix import' }],
|
|
69
|
+
raw: actions
|
|
70
|
+
});
|
|
71
|
+
expect(promises_1.writeFile).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
it('applies the selected code action edit and command', async () => {
|
|
74
|
+
const registrar = new FakeRegistrar();
|
|
75
|
+
const client = createClient([
|
|
76
|
+
{
|
|
77
|
+
title: 'Fix import',
|
|
78
|
+
edit: {
|
|
79
|
+
changes: {
|
|
80
|
+
'file:///workspace/src/index.ts': [{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, newText: 'let' }]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
command: { command: 'workspace.applyFix', arguments: ['x'] }
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
87
|
+
const result = await getHandler(registrar, 'lsp_code_action')({ file: '/workspace/src/index.ts', line: 0, character: 0, apply: true });
|
|
88
|
+
expect(client.request).toHaveBeenCalledWith('workspace/executeCommand', { command: 'workspace.applyFix', arguments: ['x'] }, 15000);
|
|
89
|
+
expect(result).toEqual({
|
|
90
|
+
content: [{ type: 'text', text: 'Applied code action: Fix import' }],
|
|
91
|
+
raw: { title: 'Fix import', changedFiles: ['/workspace/src/index.ts'] }
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
it('formats a file using editorconfig defaults', async () => {
|
|
95
|
+
const registrar = new FakeRegistrar();
|
|
96
|
+
const client = createClient([{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 21 } }, newText: 'const foo = oldName;\n' }]);
|
|
97
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
98
|
+
await getHandler(registrar, 'lsp_formatting')({ file: '/workspace/src/index.ts' });
|
|
99
|
+
expect(client.request).toHaveBeenCalledWith('textDocument/formatting', {
|
|
100
|
+
textDocument: { uri: 'file:///workspace/src/index.ts' },
|
|
101
|
+
options: { tabSize: 2, insertSpaces: true }
|
|
102
|
+
}, 15000);
|
|
103
|
+
});
|
|
104
|
+
it('formats a range with explicit options', async () => {
|
|
105
|
+
const registrar = new FakeRegistrar();
|
|
106
|
+
const client = createClient([{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 21 } }, newText: 'const foo = oldName;\n' }]);
|
|
107
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
108
|
+
await getHandler(registrar, 'lsp_range_formatting')({
|
|
109
|
+
file: '/workspace/src/index.ts',
|
|
110
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 21 } },
|
|
111
|
+
options: { tabSize: 4, insertSpaces: false }
|
|
112
|
+
});
|
|
113
|
+
expect(client.request).toHaveBeenCalledWith('textDocument/rangeFormatting', {
|
|
114
|
+
textDocument: { uri: 'file:///workspace/src/index.ts' },
|
|
115
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 21 } },
|
|
116
|
+
options: { tabSize: 4, insertSpaces: false }
|
|
117
|
+
}, 15000);
|
|
118
|
+
});
|
|
119
|
+
it('applies a raw workspace edit across files', async () => {
|
|
120
|
+
const registrar = new FakeRegistrar();
|
|
121
|
+
const client = createClient(null);
|
|
122
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
123
|
+
const edit = {
|
|
124
|
+
changes: {
|
|
125
|
+
'file:///workspace/src/index.ts': [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 9 } }, newText: 'bar' }]
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
await expect(getHandler(registrar, 'lsp_apply_workspace_edit')({ edit })).resolves.toEqual({
|
|
129
|
+
content: [{ type: 'text', text: 'Applied workspace edit to 1 file(s)' }],
|
|
130
|
+
raw: { changedFiles: ['/workspace/src/index.ts'] }
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
it('supports indexed code actions, empty edits, and document changes', async () => {
|
|
134
|
+
const registrar = new FakeRegistrar();
|
|
135
|
+
const client = createClient([
|
|
136
|
+
{ title: 'Skip me' },
|
|
137
|
+
{
|
|
138
|
+
title: 'Apply me',
|
|
139
|
+
edit: {
|
|
140
|
+
documentChanges: [
|
|
141
|
+
{
|
|
142
|
+
textDocument: { uri: 'file:///workspace/src/index.ts', version: 1 },
|
|
143
|
+
edits: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 9 } }, newText: 'bar' }]
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
]);
|
|
149
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
150
|
+
await expect(getHandler(registrar, 'lsp_code_action')({ file: '/workspace/src/index.ts', line: 0, character: 0, apply: { index: 1 } })).resolves.toEqual({
|
|
151
|
+
content: [{ type: 'text', text: 'Applied code action: Apply me' }],
|
|
152
|
+
raw: { title: 'Apply me', changedFiles: ['/workspace/src/index.ts'] }
|
|
153
|
+
});
|
|
154
|
+
client.request.mockResolvedValueOnce(null);
|
|
155
|
+
await expect(getHandler(registrar, 'lsp_apply_workspace_edit')({ edit: null })).resolves.toEqual({
|
|
156
|
+
content: [{ type: 'text', text: 'No result' }],
|
|
157
|
+
raw: null
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it('returns an error when no write-capable client is ready', async () => {
|
|
161
|
+
const registrar = new FakeRegistrar();
|
|
162
|
+
(0, write_tools_1.registerWriteTools)(registrar, {
|
|
163
|
+
getClientForFile: jest.fn(() => null),
|
|
164
|
+
getReadyClients: jest.fn(() => []),
|
|
165
|
+
getFileDiagnostics: jest.fn((_) => []),
|
|
166
|
+
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
167
|
+
getHealth: jest.fn(() => []),
|
|
168
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
169
|
+
});
|
|
170
|
+
await expect(getHandler(registrar, 'lsp_apply_workspace_edit')({ edit: {} })).resolves.toEqual({
|
|
171
|
+
content: [{ type: 'text', text: 'No language servers are ready. Run lsp_health for details.' }],
|
|
172
|
+
error: true,
|
|
173
|
+
raw: null
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
it('handles missing clients, formatter defaults, and empty code actions', async () => {
|
|
177
|
+
const registrar = new FakeRegistrar();
|
|
178
|
+
const noClientLifecycle = {
|
|
179
|
+
getClientForFile: jest.fn(() => null),
|
|
180
|
+
getReadyClients: jest.fn(() => []),
|
|
181
|
+
getFileDiagnostics: jest.fn((_) => []),
|
|
182
|
+
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
183
|
+
getHealth: jest.fn(() => []),
|
|
184
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
185
|
+
};
|
|
186
|
+
(0, write_tools_1.registerWriteTools)(registrar, noClientLifecycle);
|
|
187
|
+
await expect(getHandler(registrar, 'lsp_rename')({ file: '/workspace/README.md', line: 0, character: 0, newName: 'x' })).resolves.toEqual({
|
|
188
|
+
content: [{ type: 'text', text: 'No language server available for .md files. Run lsp_health for details.' }],
|
|
189
|
+
error: true,
|
|
190
|
+
raw: null
|
|
191
|
+
});
|
|
192
|
+
await expect(getHandler(registrar, 'lsp_code_action')({ file: '/workspace/README.md', line: 0, character: 0 })).resolves.toEqual({
|
|
193
|
+
content: [{ type: 'text', text: 'No language server available for .md files. Run lsp_health for details.' }],
|
|
194
|
+
error: true,
|
|
195
|
+
raw: null
|
|
196
|
+
});
|
|
197
|
+
await expect(getHandler(registrar, 'lsp_formatting')({ file: '/workspace/README.md' })).resolves.toEqual({
|
|
198
|
+
content: [{ type: 'text', text: 'No language server available for .md files. Run lsp_health for details.' }],
|
|
199
|
+
error: true,
|
|
200
|
+
raw: null
|
|
201
|
+
});
|
|
202
|
+
const secondRegistrar = new FakeRegistrar();
|
|
203
|
+
const client = createClient([]);
|
|
204
|
+
promises_1.access.mockRejectedValue(new Error('missing'));
|
|
205
|
+
(0, write_tools_1.registerWriteTools)(secondRegistrar, createLifecycle(client));
|
|
206
|
+
await expect(getHandler(secondRegistrar, 'lsp_formatting')({ file: '/workspace/src/index.ts' })).resolves.toEqual({
|
|
207
|
+
content: [{ type: 'text', text: 'Applied workspace edit to 0 file(s)' }],
|
|
208
|
+
raw: { changedFiles: [] }
|
|
209
|
+
});
|
|
210
|
+
await expect(getHandler(secondRegistrar, 'lsp_code_action')({ file: '/workspace/src/index.ts', line: 0, character: 0 })).resolves.toEqual({
|
|
211
|
+
content: [{ type: 'text', text: 'No result' }],
|
|
212
|
+
raw: []
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
it('maps write failures to timeout guidance', async () => {
|
|
216
|
+
const registrar = new FakeRegistrar();
|
|
217
|
+
const client = createClient(new Error('LSP request timed out: textDocument/rename'));
|
|
218
|
+
(0, write_tools_1.registerWriteTools)(registrar, createLifecycle(client));
|
|
219
|
+
await expect(getHandler(registrar, 'lsp_rename')({ file: '/workspace/src/index.ts', line: 0, character: 0, newName: 'x' })).resolves.toEqual({
|
|
220
|
+
content: [{ type: 'text', text: 'Operation timed out after 15s — try a more specific query or check the LSP server health' }],
|
|
221
|
+
error: true,
|
|
222
|
+
raw: null
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
function getHandler(registrar, name) {
|
|
227
|
+
const tool = registrar.tools.get(name);
|
|
228
|
+
if (!tool) {
|
|
229
|
+
throw new Error(`Missing tool ${name}`);
|
|
230
|
+
}
|
|
231
|
+
return tool.handler;
|
|
232
|
+
}
|
|
233
|
+
function createLifecycle(client) {
|
|
234
|
+
return {
|
|
235
|
+
getClientForFile: jest.fn((_) => client),
|
|
236
|
+
getReadyClients: jest.fn(() => [client]),
|
|
237
|
+
getFileDiagnostics: jest.fn((_) => []),
|
|
238
|
+
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
239
|
+
getHealth: jest.fn(() => []),
|
|
240
|
+
ensureLanguageForFile: jest.fn().mockResolvedValue(undefined)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function createClient(result, capabilities = { renameProvider: true }) {
|
|
244
|
+
return {
|
|
245
|
+
request: jest.fn().mockImplementation(async (method) => {
|
|
246
|
+
if (method === 'workspace/executeCommand') {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (result instanceof Error) {
|
|
250
|
+
throw result;
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
}),
|
|
254
|
+
notify: jest.fn(),
|
|
255
|
+
getCapabilities: jest.fn(() => capabilities)
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatHover = formatHover;
|
|
4
|
+
exports.formatDefinition = formatDefinition;
|
|
5
|
+
exports.formatReferences = formatReferences;
|
|
6
|
+
exports.formatSymbols = formatSymbols;
|
|
7
|
+
exports.formatDiagnostics = formatDiagnostics;
|
|
8
|
+
exports.formatCompletion = formatCompletion;
|
|
9
|
+
exports.formatHealth = formatHealth;
|
|
10
|
+
exports.formatError = formatError;
|
|
11
|
+
const uri_1 = require("../utils/uri");
|
|
12
|
+
const SYMBOL_KIND_ICONS = {
|
|
13
|
+
1: '📄',
|
|
14
|
+
2: '📦',
|
|
15
|
+
3: '🔖',
|
|
16
|
+
4: '🧩',
|
|
17
|
+
5: '📦',
|
|
18
|
+
6: '🔧',
|
|
19
|
+
7: '🏗️',
|
|
20
|
+
8: '🧠',
|
|
21
|
+
9: '📐',
|
|
22
|
+
10: '📚',
|
|
23
|
+
11: '🔌',
|
|
24
|
+
12: 'ƒ',
|
|
25
|
+
13: '≡',
|
|
26
|
+
14: '🔒',
|
|
27
|
+
15: '📝',
|
|
28
|
+
16: '№',
|
|
29
|
+
17: '🧮',
|
|
30
|
+
18: '📏',
|
|
31
|
+
19: '🧱',
|
|
32
|
+
20: '🔑',
|
|
33
|
+
21: '❌',
|
|
34
|
+
22: '🧩',
|
|
35
|
+
23: '➡️',
|
|
36
|
+
24: '🎯',
|
|
37
|
+
25: '📦',
|
|
38
|
+
26: '🔎'
|
|
39
|
+
};
|
|
40
|
+
const COMPLETION_KIND_LABELS = {
|
|
41
|
+
2: 'Methods',
|
|
42
|
+
3: 'Functions',
|
|
43
|
+
4: 'Constructors',
|
|
44
|
+
5: 'Fields',
|
|
45
|
+
6: 'Variables',
|
|
46
|
+
7: 'Classes',
|
|
47
|
+
8: 'Interfaces',
|
|
48
|
+
9: 'Modules',
|
|
49
|
+
10: 'Properties',
|
|
50
|
+
11: 'Units',
|
|
51
|
+
12: 'Values',
|
|
52
|
+
13: 'Enums',
|
|
53
|
+
14: 'Keywords',
|
|
54
|
+
15: 'Snippets',
|
|
55
|
+
16: 'Colors',
|
|
56
|
+
17: 'Files',
|
|
57
|
+
18: 'References',
|
|
58
|
+
19: 'Folders',
|
|
59
|
+
20: 'Enum members',
|
|
60
|
+
21: 'Constants',
|
|
61
|
+
22: 'Structs',
|
|
62
|
+
23: 'Events',
|
|
63
|
+
24: 'Operators',
|
|
64
|
+
25: 'Type parameters'
|
|
65
|
+
};
|
|
66
|
+
const DIAGNOSTIC_SEVERITY_LABELS = {
|
|
67
|
+
1: 'Errors',
|
|
68
|
+
2: 'Warnings',
|
|
69
|
+
3: 'Information',
|
|
70
|
+
4: 'Hints'
|
|
71
|
+
};
|
|
72
|
+
function formatHover(result) {
|
|
73
|
+
if (!result) {
|
|
74
|
+
return 'No result';
|
|
75
|
+
}
|
|
76
|
+
const rawText = hoverContentsToText(result.contents).trim();
|
|
77
|
+
if (!rawText) {
|
|
78
|
+
return 'No result';
|
|
79
|
+
}
|
|
80
|
+
const codeBlocks = Array.from(rawText.matchAll(/```(?<lang>[^\n`]*)\n(?<code>[\s\S]*?)```/g));
|
|
81
|
+
const firstCode = codeBlocks[0]?.groups?.code?.trim();
|
|
82
|
+
const firstLang = codeBlocks[0]?.groups?.lang?.trim() ?? '';
|
|
83
|
+
const summarySource = rawText.replace(/```[\s\S]*?```/g, '').trim();
|
|
84
|
+
const summaryLine = summarySource.split(/\n+/).find(Boolean) ?? '';
|
|
85
|
+
if (firstCode && summaryLine) {
|
|
86
|
+
return `**${firstCode}** — ${summaryLine}\n\n\`\`\`${firstLang}\n${firstCode}\n\`\`\``;
|
|
87
|
+
}
|
|
88
|
+
return summaryLine || firstCode || rawText;
|
|
89
|
+
}
|
|
90
|
+
function formatDefinition(locations) {
|
|
91
|
+
if (!locations || locations.length === 0) {
|
|
92
|
+
return 'No result';
|
|
93
|
+
}
|
|
94
|
+
if (locations.length === 1) {
|
|
95
|
+
return `Found 1 definition: \`${formatLocation(locations[0])}\``;
|
|
96
|
+
}
|
|
97
|
+
return ['Found definitions:', ...locations.map((location) => `- \`${formatLocation(location)}\``)].join('\n');
|
|
98
|
+
}
|
|
99
|
+
function formatReferences(locations) {
|
|
100
|
+
if (!locations || locations.length === 0) {
|
|
101
|
+
return 'No result';
|
|
102
|
+
}
|
|
103
|
+
return [`Found ${locations.length} references:`, ...locations.map((location) => `- \`${formatLocation(location)}\``)].join('\n');
|
|
104
|
+
}
|
|
105
|
+
function formatSymbols(symbols) {
|
|
106
|
+
if (!symbols || symbols.length === 0) {
|
|
107
|
+
return 'No result';
|
|
108
|
+
}
|
|
109
|
+
return symbols.map((symbol) => {
|
|
110
|
+
const icon = SYMBOL_KIND_ICONS[symbol.kind] ?? '•';
|
|
111
|
+
const detail = 'location' in symbol
|
|
112
|
+
? formatLocation(symbol.location)
|
|
113
|
+
: `${symbol.name}`;
|
|
114
|
+
return `- ${icon} \`${symbol.name}\`${'location' in symbol ? ` — ${detail}` : ''}`;
|
|
115
|
+
}).join('\n');
|
|
116
|
+
}
|
|
117
|
+
function formatDiagnostics(diagnostics, scope) {
|
|
118
|
+
if (!diagnostics || diagnostics.length === 0) {
|
|
119
|
+
return 'No result';
|
|
120
|
+
}
|
|
121
|
+
const grouped = new Map();
|
|
122
|
+
const ordered = [...diagnostics].sort((left, right) => (left.severity ?? 4) - (right.severity ?? 4));
|
|
123
|
+
for (const diagnostic of ordered) {
|
|
124
|
+
const severity = diagnostic.severity ?? 4;
|
|
125
|
+
const bucket = grouped.get(severity) ?? [];
|
|
126
|
+
bucket.push(diagnostic);
|
|
127
|
+
grouped.set(severity, bucket);
|
|
128
|
+
}
|
|
129
|
+
const lines = [`${scope === 'workspace' ? 'Workspace' : 'File'} diagnostics: ${diagnostics.length} issue(s)`];
|
|
130
|
+
for (const severity of [1, 2, 3, 4]) {
|
|
131
|
+
const bucket = grouped.get(severity);
|
|
132
|
+
if (!bucket || bucket.length === 0) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
lines.push('', `### ${DIAGNOSTIC_SEVERITY_LABELS[severity]}`);
|
|
136
|
+
for (const diagnostic of bucket) {
|
|
137
|
+
lines.push(`- ${formatDiagnostic(diagnostic)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
function formatCompletion(items) {
|
|
143
|
+
if (!items || items.length === 0) {
|
|
144
|
+
return 'No result';
|
|
145
|
+
}
|
|
146
|
+
const limited = items.slice(0, 50);
|
|
147
|
+
const grouped = new Map();
|
|
148
|
+
for (const item of limited) {
|
|
149
|
+
const label = COMPLETION_KIND_LABELS[item.kind ?? 1] ?? 'Other';
|
|
150
|
+
const bucket = grouped.get(label) ?? [];
|
|
151
|
+
bucket.push(item);
|
|
152
|
+
grouped.set(label, bucket);
|
|
153
|
+
}
|
|
154
|
+
const lines = [`Showing ${limited.length} of ${items.length} completion item(s)`];
|
|
155
|
+
for (const [label, bucket] of grouped.entries()) {
|
|
156
|
+
lines.push('', `### ${label}`);
|
|
157
|
+
for (const item of bucket) {
|
|
158
|
+
lines.push(`- \`${item.label}\`${item.detail ? ` — ${item.detail}` : ''}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
}
|
|
163
|
+
function formatHealth(healths) {
|
|
164
|
+
if (healths.length === 0) {
|
|
165
|
+
return 'No result';
|
|
166
|
+
}
|
|
167
|
+
return [
|
|
168
|
+
'| Language | Status | Error |',
|
|
169
|
+
'| --- | --- | --- |',
|
|
170
|
+
...healths.map((health) => `| ${health.language} | ${health.status} | ${health.error ?? ''} |`)
|
|
171
|
+
].join('\n');
|
|
172
|
+
}
|
|
173
|
+
function formatError(error) {
|
|
174
|
+
return {
|
|
175
|
+
error: true,
|
|
176
|
+
text: error instanceof Error ? error.message : String(error),
|
|
177
|
+
raw: error
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function hoverContentsToText(contents) {
|
|
181
|
+
if (typeof contents === 'string') {
|
|
182
|
+
return contents;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(contents)) {
|
|
185
|
+
return contents.map(markedStringToText).join('\n\n');
|
|
186
|
+
}
|
|
187
|
+
if ('kind' in contents) {
|
|
188
|
+
return contents.value;
|
|
189
|
+
}
|
|
190
|
+
return markedStringToText(contents);
|
|
191
|
+
}
|
|
192
|
+
function markedStringToText(value) {
|
|
193
|
+
return typeof value === 'string' ? value : `\`\`\`${value.language}\n${value.value}\n\`\`\``;
|
|
194
|
+
}
|
|
195
|
+
function formatLocation(location) {
|
|
196
|
+
return `${(0, uri_1.uriToPath)(location.uri)}:${location.range.start.line + 1}:${location.range.start.character + 1}`;
|
|
197
|
+
}
|
|
198
|
+
function formatDiagnostic(diagnostic) {
|
|
199
|
+
const location = diagnostic.uri ? `\`${(0, uri_1.uriToPath)(diagnostic.uri)}:${diagnostic.range.start.line + 1}:${diagnostic.range.start.character + 1}\` ` : '';
|
|
200
|
+
const source = diagnostic.source ? `${diagnostic.source}: ` : '';
|
|
201
|
+
return `${location}${source}${diagnostic.message}`.trim();
|
|
202
|
+
}
|