@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,107 @@
|
|
|
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.detectLanguages = detectLanguages;
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const MARKER_DEFINITIONS = [
|
|
10
|
+
{ language: 'javascript', markers: ['package.json'] },
|
|
11
|
+
{ language: 'typescript', markers: ['tsconfig.json'] },
|
|
12
|
+
{ language: 'python', markers: ['pyproject.toml', 'setup.py'] },
|
|
13
|
+
{ language: 'csharp', markers: ['.csproj', '.sln'] },
|
|
14
|
+
{ language: 'rust', markers: ['Cargo.toml'] },
|
|
15
|
+
{ language: 'go', markers: ['go.mod'] },
|
|
16
|
+
{ language: 'java', markers: ['pom.xml', 'build.gradle'] },
|
|
17
|
+
{ language: 'ruby', markers: ['Gemfile'] },
|
|
18
|
+
{ language: 'php', markers: ['composer.json'] },
|
|
19
|
+
{ language: 'kotlin', markers: ['build.gradle.kts'] },
|
|
20
|
+
{ language: 'swift', markers: ['Package.swift'] }
|
|
21
|
+
];
|
|
22
|
+
const EXTENSION_DEFINITIONS = [
|
|
23
|
+
{ language: 'c', extensions: ['.c', '.h'] },
|
|
24
|
+
{ language: 'cpp', extensions: ['.cpp', '.hpp', '.cc'] }
|
|
25
|
+
];
|
|
26
|
+
async function detectLanguages(projectRoot) {
|
|
27
|
+
const entries = await collectEntries(projectRoot, projectRoot);
|
|
28
|
+
const markerMatches = new Map();
|
|
29
|
+
for (const definition of MARKER_DEFINITIONS) {
|
|
30
|
+
const matches = entries
|
|
31
|
+
.filter((entry) => definition.markers.some((marker) => matchesMarker(entry.relativePath, marker)))
|
|
32
|
+
.map((entry) => entry.relativePath);
|
|
33
|
+
if (matches.length > 0) {
|
|
34
|
+
markerMatches.set(definition.language, matches.sort());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const detected = [];
|
|
38
|
+
if (markerMatches.has('javascript')) {
|
|
39
|
+
detected.push({
|
|
40
|
+
language: 'javascript',
|
|
41
|
+
confidence: 'marker',
|
|
42
|
+
markers: markerMatches.get('javascript') ?? []
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (markerMatches.has('typescript')) {
|
|
46
|
+
detected.push({
|
|
47
|
+
language: 'typescript',
|
|
48
|
+
confidence: 'marker',
|
|
49
|
+
markers: markerMatches.has('javascript')
|
|
50
|
+
? ['package.json', ...(markerMatches.get('typescript') ?? [])]
|
|
51
|
+
: (markerMatches.get('typescript') ?? [])
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const definition of MARKER_DEFINITIONS) {
|
|
55
|
+
if (definition.language === 'javascript' || definition.language === 'typescript') {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const matches = markerMatches.get(definition.language);
|
|
59
|
+
if (matches && matches.length > 0) {
|
|
60
|
+
detected.push({
|
|
61
|
+
language: definition.language,
|
|
62
|
+
confidence: 'marker',
|
|
63
|
+
markers: matches
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const definition of EXTENSION_DEFINITIONS) {
|
|
68
|
+
const matches = entries
|
|
69
|
+
.filter((entry) => definition.extensions.some((extension) => entry.relativePath.endsWith(extension)))
|
|
70
|
+
.map((entry) => entry.relativePath)
|
|
71
|
+
.sort((left, right) => compareByExtensionPriority(left, right, definition.extensions));
|
|
72
|
+
if (matches.length > 0) {
|
|
73
|
+
detected.push({
|
|
74
|
+
language: definition.language,
|
|
75
|
+
confidence: 'extension',
|
|
76
|
+
markers: matches
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return detected;
|
|
81
|
+
}
|
|
82
|
+
function compareByExtensionPriority(left, right, extensions) {
|
|
83
|
+
const leftIndex = extensions.findIndex((extension) => left.endsWith(extension));
|
|
84
|
+
const rightIndex = extensions.findIndex((extension) => right.endsWith(extension));
|
|
85
|
+
if (leftIndex !== rightIndex) {
|
|
86
|
+
return leftIndex - rightIndex;
|
|
87
|
+
}
|
|
88
|
+
return left.localeCompare(right);
|
|
89
|
+
}
|
|
90
|
+
async function collectEntries(root, currentDir) {
|
|
91
|
+
const dirents = await (0, promises_1.readdir)(currentDir, { withFileTypes: true });
|
|
92
|
+
const entries = [];
|
|
93
|
+
for (const dirent of dirents) {
|
|
94
|
+
const absolutePath = node_path_1.default.join(currentDir, dirent.name);
|
|
95
|
+
if (dirent.isDirectory()) {
|
|
96
|
+
entries.push(...await collectEntries(root, absolutePath));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
entries.push({
|
|
100
|
+
relativePath: node_path_1.default.relative(root, absolutePath).split(node_path_1.default.sep).join('/')
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
function matchesMarker(relativePath, marker) {
|
|
106
|
+
return marker.startsWith('.') ? relativePath.endsWith(marker) : relativePath === marker;
|
|
107
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SUPPORTED_LANGUAGES = void 0;
|
|
4
|
+
exports.getLspCandidates = getLspCandidates;
|
|
5
|
+
exports.findAvailableLsp = findAvailableLsp;
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_util_1 = require("node:util");
|
|
8
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
9
|
+
const TYPESCRIPT_CANDIDATES = [
|
|
10
|
+
{ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' }
|
|
11
|
+
];
|
|
12
|
+
const CLANGD_CANDIDATES = [
|
|
13
|
+
{ cmd: 'clangd', args: [], pkg: 'clangd', mgr: 'apt' }
|
|
14
|
+
];
|
|
15
|
+
const LANGUAGE_TO_CANDIDATES = {
|
|
16
|
+
python: [
|
|
17
|
+
{ cmd: 'pyright-langserver', args: ['--stdio'], pkg: 'pyright', mgr: 'npm' },
|
|
18
|
+
{ cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' }
|
|
19
|
+
],
|
|
20
|
+
typescript: TYPESCRIPT_CANDIDATES,
|
|
21
|
+
javascript: TYPESCRIPT_CANDIDATES,
|
|
22
|
+
csharp: [
|
|
23
|
+
{ cmd: 'omnisharp', args: ['-lsp'], pkg: 'omnisharp-roslyn', mgr: 'dotnet' }
|
|
24
|
+
],
|
|
25
|
+
java: [
|
|
26
|
+
{ cmd: 'java-language-server', args: [], pkg: 'vscode-java', mgr: 'npm' },
|
|
27
|
+
{ cmd: 'jdtls', args: [], pkg: 'eclipse.jdt.ls', mgr: 'apt' }
|
|
28
|
+
],
|
|
29
|
+
go: [
|
|
30
|
+
{ cmd: 'gopls', args: ['serve'], pkg: 'golang.org/x/tools/gopls', mgr: 'go' }
|
|
31
|
+
],
|
|
32
|
+
rust: [
|
|
33
|
+
{ cmd: 'rust-analyzer', args: [], pkg: 'rust-analyzer', mgr: 'cargo' }
|
|
34
|
+
],
|
|
35
|
+
c: CLANGD_CANDIDATES,
|
|
36
|
+
cpp: CLANGD_CANDIDATES,
|
|
37
|
+
ruby: [
|
|
38
|
+
{ cmd: 'solargraph', args: ['stdio'], pkg: 'solargraph', mgr: 'gem' }
|
|
39
|
+
],
|
|
40
|
+
php: [
|
|
41
|
+
{ cmd: 'intelephense', args: ['--stdio'], pkg: 'intelephense', mgr: 'npm' }
|
|
42
|
+
],
|
|
43
|
+
kotlin: [
|
|
44
|
+
{ cmd: 'kotlin-language-server', args: ['--stdio'], pkg: 'kotlin-language-server', mgr: 'npm' }
|
|
45
|
+
],
|
|
46
|
+
swift: [
|
|
47
|
+
{ cmd: 'sourcekit-lsp', args: [], pkg: 'sourcekit-lsp', mgr: 'brew' }
|
|
48
|
+
]
|
|
49
|
+
};
|
|
50
|
+
exports.SUPPORTED_LANGUAGES = Object.freeze([
|
|
51
|
+
'python',
|
|
52
|
+
'typescript',
|
|
53
|
+
'javascript',
|
|
54
|
+
'csharp',
|
|
55
|
+
'java',
|
|
56
|
+
'go',
|
|
57
|
+
'rust',
|
|
58
|
+
'c',
|
|
59
|
+
'cpp',
|
|
60
|
+
'ruby',
|
|
61
|
+
'php',
|
|
62
|
+
'kotlin',
|
|
63
|
+
'swift'
|
|
64
|
+
]);
|
|
65
|
+
function getLspCandidates(language) {
|
|
66
|
+
return LANGUAGE_TO_CANDIDATES[language]?.map((candidate) => ({ ...candidate, args: [...candidate.args] })) ?? [];
|
|
67
|
+
}
|
|
68
|
+
async function findAvailableLsp(language) {
|
|
69
|
+
const candidates = getLspCandidates(language);
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
if (await commandExists(candidate.cmd)) {
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
async function commandExists(command) {
|
|
78
|
+
const lookupCommand = process.platform === 'win32' ? 'where' : 'which';
|
|
79
|
+
try {
|
|
80
|
+
await execFileAsync(lookupCommand, [command], { env: { ...process.env } });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.main = main;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const server_1 = require("./mcp/server");
|
|
11
|
+
async function main(argv = process.argv.slice(2), env = process.env, overrides = {}) {
|
|
12
|
+
const stdout = overrides.stdout ?? ((text) => process.stdout.write(text));
|
|
13
|
+
const stderr = overrides.stderr ?? ((text) => process.stderr.write(text));
|
|
14
|
+
const exit = overrides.exit ?? ((code) => process.exit(code));
|
|
15
|
+
const onSignal = overrides.onSignal ?? ((signal, handler) => {
|
|
16
|
+
process.on(signal, () => {
|
|
17
|
+
void handler();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
if (argv.includes('--version')) {
|
|
21
|
+
stdout(`${readVersion()}\n`);
|
|
22
|
+
exit(0);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const projectRoot = env.LSP_MCP_ROOT;
|
|
26
|
+
const logLevel = env.LSP_MCP_LOG_LEVEL ?? 'info';
|
|
27
|
+
const mcpServer = new server_1.McpServer(logLevel);
|
|
28
|
+
if (projectRoot) {
|
|
29
|
+
const initialized = await mcpServer.initializeManager(projectRoot);
|
|
30
|
+
const startupReport = summarizeHealth(initialized.health);
|
|
31
|
+
stderr(`${JSON.stringify(startupReport)}\n`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
stderr(`${JSON.stringify({ event: 'startup', status: 'waiting-for-init' })}\n`);
|
|
35
|
+
}
|
|
36
|
+
await mcpServer.start();
|
|
37
|
+
const shutdown = async () => {
|
|
38
|
+
await mcpServer.shutdown();
|
|
39
|
+
exit(0);
|
|
40
|
+
};
|
|
41
|
+
onSignal('SIGINT', shutdown);
|
|
42
|
+
onSignal('SIGTERM', shutdown);
|
|
43
|
+
}
|
|
44
|
+
function summarizeHealth(health) {
|
|
45
|
+
return {
|
|
46
|
+
languages: health.map((entry) => entry.language),
|
|
47
|
+
started: health.filter((entry) => entry.status === 'ready').map((entry) => entry.language),
|
|
48
|
+
errors: health.filter((entry) => entry.status === 'error' && entry.error).map((entry) => entry.error)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function readVersion() {
|
|
52
|
+
const packageJsonPath = node_path_1.default.resolve(__dirname, '..', 'package.json');
|
|
53
|
+
return JSON.parse((0, node_fs_1.readFileSync)(packageJsonPath, 'utf8')).version;
|
|
54
|
+
}
|
|
55
|
+
if (require.main === module) {
|
|
56
|
+
void main().catch((error) => {
|
|
57
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const installer_1 = require("../installer");
|
|
4
|
+
jest.mock('node:child_process', () => ({
|
|
5
|
+
exec: jest.fn()
|
|
6
|
+
}));
|
|
7
|
+
describe('installLsp', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
jest.clearAllMocks();
|
|
10
|
+
});
|
|
11
|
+
it('installs npm packages into the user local prefix', async () => {
|
|
12
|
+
const exec = jest.requireMock('node:child_process').exec;
|
|
13
|
+
exec.mockImplementation((...args) => {
|
|
14
|
+
const callback = args[args.length - 1];
|
|
15
|
+
if (typeof callback === 'function') {
|
|
16
|
+
callback(null, '', '');
|
|
17
|
+
}
|
|
18
|
+
return { on: jest.fn() };
|
|
19
|
+
});
|
|
20
|
+
const result = await (0, installer_1.installLsp)({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
21
|
+
expect(result).toEqual({ success: true });
|
|
22
|
+
expect(exec).toHaveBeenCalledWith('npm install --global --prefix "$HOME/.local" typescript-language-server', expect.objectContaining({ env: process.env }), expect.any(Function));
|
|
23
|
+
});
|
|
24
|
+
it('installs pip packages into the user site directory', async () => {
|
|
25
|
+
const exec = jest.requireMock('node:child_process').exec;
|
|
26
|
+
exec.mockImplementation((...args) => {
|
|
27
|
+
const callback = args[args.length - 1];
|
|
28
|
+
if (typeof callback === 'function') {
|
|
29
|
+
callback(null, '', '');
|
|
30
|
+
}
|
|
31
|
+
return { on: jest.fn() };
|
|
32
|
+
});
|
|
33
|
+
const result = await (0, installer_1.installLsp)({ cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' });
|
|
34
|
+
expect(result).toEqual({ success: true });
|
|
35
|
+
expect(exec).toHaveBeenCalledWith('pip install --user python-lsp-server', expect.objectContaining({ env: process.env }), expect.any(Function));
|
|
36
|
+
});
|
|
37
|
+
it.each([
|
|
38
|
+
[{ cmd: 'gopls', args: ['serve'], pkg: 'golang.org/x/tools/gopls', mgr: 'go' }, 'go install golang.org/x/tools/gopls@latest'],
|
|
39
|
+
[{ cmd: 'solargraph', args: ['stdio'], pkg: 'solargraph', mgr: 'gem' }, 'gem install --user-install solargraph'],
|
|
40
|
+
[{ cmd: 'rust-analyzer', args: [], pkg: 'rust-analyzer', mgr: 'cargo' }, 'cargo install rust-analyzer']
|
|
41
|
+
])('uses a user-local install command for %s', async (candidate, expectedCommand) => {
|
|
42
|
+
const exec = jest.requireMock('node:child_process').exec;
|
|
43
|
+
exec.mockImplementation((...args) => {
|
|
44
|
+
const callback = args[args.length - 1];
|
|
45
|
+
if (typeof callback === 'function') {
|
|
46
|
+
callback(null, '', '');
|
|
47
|
+
}
|
|
48
|
+
return { on: jest.fn() };
|
|
49
|
+
});
|
|
50
|
+
const result = await (0, installer_1.installLsp)(candidate);
|
|
51
|
+
expect(result).toEqual({ success: true });
|
|
52
|
+
expect(exec).toHaveBeenCalledWith(expectedCommand, expect.objectContaining({ env: process.env }), expect.any(Function));
|
|
53
|
+
});
|
|
54
|
+
it('returns a structured failure with manual instructions when install command fails', async () => {
|
|
55
|
+
const exec = jest.requireMock('node:child_process').exec;
|
|
56
|
+
exec.mockImplementation((...args) => {
|
|
57
|
+
const callback = args[args.length - 1];
|
|
58
|
+
if (typeof callback === 'function') {
|
|
59
|
+
callback(new Error('permission denied'), '', '');
|
|
60
|
+
}
|
|
61
|
+
return { on: jest.fn() };
|
|
62
|
+
});
|
|
63
|
+
const result = await (0, installer_1.installLsp)({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
success: false,
|
|
66
|
+
error: 'permission denied',
|
|
67
|
+
instructions: 'npm install -g typescript-language-server'
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it('returns manual instructions for package managers without a user-local path', async () => {
|
|
71
|
+
const exec = jest.requireMock('node:child_process').exec;
|
|
72
|
+
const result = await (0, installer_1.installLsp)({ cmd: 'omnisharp', args: ['-lsp'], pkg: 'omnisharp-roslyn', mgr: 'dotnet' });
|
|
73
|
+
expect(result).toEqual({
|
|
74
|
+
success: false,
|
|
75
|
+
error: 'Automatic user-local install is not supported for dotnet',
|
|
76
|
+
instructions: 'dotnet tool install -g omnisharp-roslyn'
|
|
77
|
+
});
|
|
78
|
+
expect(exec).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
it.each([
|
|
81
|
+
[{ cmd: 'clangd', args: [], pkg: 'clangd', mgr: 'apt' }, 'sudo apt install clangd'],
|
|
82
|
+
[{ cmd: 'sourcekit-lsp', args: [], pkg: 'sourcekit-lsp', mgr: 'brew' }, 'brew install sourcekit-lsp'],
|
|
83
|
+
[{ cmd: 'mystery-lsp', args: [], pkg: 'mystery-lsp', mgr: 'custom' }, 'custom install mystery-lsp']
|
|
84
|
+
])('returns manual instructions for unsupported %s installs', async (candidate, instructions) => {
|
|
85
|
+
const result = await (0, installer_1.installLsp)({ ...candidate, args: [...candidate.args] });
|
|
86
|
+
expect(result).toEqual({
|
|
87
|
+
success: false,
|
|
88
|
+
error: `Automatic user-local install is not supported for ${candidate.mgr}`,
|
|
89
|
+
instructions
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
it.each([
|
|
93
|
+
[{ cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' }, 'pip install python-lsp-server'],
|
|
94
|
+
[{ cmd: 'gopls', args: ['serve'], pkg: 'golang.org/x/tools/gopls', mgr: 'go' }, 'go install golang.org/x/tools/gopls@latest'],
|
|
95
|
+
[{ cmd: 'solargraph', args: ['stdio'], pkg: 'solargraph', mgr: 'gem' }, 'gem install solargraph'],
|
|
96
|
+
[{ cmd: 'rust-analyzer', args: [], pkg: 'rust-analyzer', mgr: 'cargo' }, 'cargo install rust-analyzer']
|
|
97
|
+
])('returns manager-specific manual instructions when %s install fails', async (candidate, instructions) => {
|
|
98
|
+
const exec = jest.requireMock('node:child_process').exec;
|
|
99
|
+
exec.mockImplementation((...args) => {
|
|
100
|
+
const callback = args[args.length - 1];
|
|
101
|
+
if (typeof callback === 'function') {
|
|
102
|
+
callback(new Error('network down'), '', '');
|
|
103
|
+
}
|
|
104
|
+
return { on: jest.fn() };
|
|
105
|
+
});
|
|
106
|
+
const result = await (0, installer_1.installLsp)({ ...candidate, args: [...candidate.args] });
|
|
107
|
+
expect(result).toEqual({
|
|
108
|
+
success: false,
|
|
109
|
+
error: 'network down',
|
|
110
|
+
instructions
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const node_events_1 = require("node:events");
|
|
4
|
+
const lifecycle_manager_1 = require("../lifecycle-manager");
|
|
5
|
+
jest.mock('../../detection/language-detector', () => ({
|
|
6
|
+
detectLanguages: jest.fn()
|
|
7
|
+
}));
|
|
8
|
+
jest.mock('../../detection/lsp-mapping', () => ({
|
|
9
|
+
findAvailableLsp: jest.fn(),
|
|
10
|
+
getLspCandidates: jest.fn()
|
|
11
|
+
}));
|
|
12
|
+
jest.mock('../installer', () => ({
|
|
13
|
+
installLsp: jest.fn()
|
|
14
|
+
}));
|
|
15
|
+
jest.mock('../lsp-client', () => ({
|
|
16
|
+
LspClient: jest.fn()
|
|
17
|
+
}));
|
|
18
|
+
class MockLspClient extends node_events_1.EventEmitter {
|
|
19
|
+
start = jest.fn().mockResolvedValue(undefined);
|
|
20
|
+
shutdown = jest.fn().mockResolvedValue(undefined);
|
|
21
|
+
request = jest.fn().mockResolvedValue({ items: [] });
|
|
22
|
+
isReady = jest.fn().mockReturnValue(true);
|
|
23
|
+
getCapabilities = jest.fn().mockReturnValue({ hoverProvider: true });
|
|
24
|
+
}
|
|
25
|
+
describe('LifecycleManager', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.useRealTimers();
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
it('starts detected language servers and reports their health', async () => {
|
|
31
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
32
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
33
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
34
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
35
|
+
detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
|
|
36
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
37
|
+
getLspCandidates.mockReturnValue([]);
|
|
38
|
+
LspClient.mockImplementation(() => new MockLspClient());
|
|
39
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
40
|
+
await manager.start();
|
|
41
|
+
expect(detectLanguages).toHaveBeenCalledWith('/workspace/project');
|
|
42
|
+
expect(findAvailableLsp).toHaveBeenCalledWith('typescript');
|
|
43
|
+
expect(manager.getClient('typescript')).not.toBeNull();
|
|
44
|
+
expect(manager.getHealth()).toEqual([
|
|
45
|
+
{
|
|
46
|
+
language: 'typescript',
|
|
47
|
+
status: 'ready',
|
|
48
|
+
capabilities: { hoverProvider: true }
|
|
49
|
+
}
|
|
50
|
+
]);
|
|
51
|
+
await manager.shutdown();
|
|
52
|
+
});
|
|
53
|
+
it('records per-language startup errors without failing the whole boot', async () => {
|
|
54
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
55
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
56
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
57
|
+
const installLsp = jest.requireMock('../installer').installLsp;
|
|
58
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
59
|
+
detectLanguages.mockResolvedValue([
|
|
60
|
+
{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] },
|
|
61
|
+
{ language: 'python', confidence: 'marker', markers: ['pyproject.toml'] }
|
|
62
|
+
]);
|
|
63
|
+
findAvailableLsp.mockImplementation(async (language) => language === 'typescript'
|
|
64
|
+
? { cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' }
|
|
65
|
+
: null);
|
|
66
|
+
getLspCandidates.mockImplementation((language) => language === 'python'
|
|
67
|
+
? [{ cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' }]
|
|
68
|
+
: []);
|
|
69
|
+
installLsp.mockResolvedValue({ success: false, error: 'install failed', instructions: 'pip install python-lsp-server' });
|
|
70
|
+
LspClient.mockImplementation(() => new MockLspClient());
|
|
71
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
72
|
+
await manager.start();
|
|
73
|
+
expect(manager.getHealth()).toEqual([
|
|
74
|
+
{
|
|
75
|
+
language: 'typescript',
|
|
76
|
+
status: 'ready',
|
|
77
|
+
capabilities: { hoverProvider: true }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
language: 'python',
|
|
81
|
+
status: 'error',
|
|
82
|
+
error: 'No LSP server available for python',
|
|
83
|
+
capabilities: undefined
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
await manager.shutdown();
|
|
87
|
+
});
|
|
88
|
+
it('routes files to clients by file extension', async () => {
|
|
89
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
90
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
91
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
92
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
93
|
+
detectLanguages.mockResolvedValue([
|
|
94
|
+
{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] },
|
|
95
|
+
{ language: 'python', confidence: 'marker', markers: ['pyproject.toml'] }
|
|
96
|
+
]);
|
|
97
|
+
findAvailableLsp.mockImplementation(async (language) => ({
|
|
98
|
+
cmd: language === 'typescript' ? 'typescript-language-server' : 'pylsp',
|
|
99
|
+
args: language === 'typescript' ? ['--stdio'] : [],
|
|
100
|
+
pkg: language === 'typescript' ? 'typescript-language-server' : 'python-lsp-server',
|
|
101
|
+
mgr: language === 'typescript' ? 'npm' : 'pip'
|
|
102
|
+
}));
|
|
103
|
+
getLspCandidates.mockReturnValue([]);
|
|
104
|
+
LspClient.mockImplementation(() => new MockLspClient());
|
|
105
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
106
|
+
await manager.start();
|
|
107
|
+
expect(manager.getClientForFile('/workspace/project/src/index.ts')).toBe(manager.getClient('typescript'));
|
|
108
|
+
expect(manager.getClientForFile('/workspace/project/app/main.py')).toBe(manager.getClient('python'));
|
|
109
|
+
expect(manager.getClientForFile('/workspace/project/README.md')).toBeNull();
|
|
110
|
+
await manager.shutdown();
|
|
111
|
+
});
|
|
112
|
+
it('restarts a client after a failed health ping', async () => {
|
|
113
|
+
jest.useFakeTimers();
|
|
114
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
115
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
116
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
117
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
118
|
+
const firstClient = new MockLspClient();
|
|
119
|
+
firstClient.request.mockRejectedValueOnce(new Error('ping timeout'));
|
|
120
|
+
const secondClient = new MockLspClient();
|
|
121
|
+
detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
|
|
122
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
123
|
+
getLspCandidates.mockReturnValue([]);
|
|
124
|
+
LspClient.mockImplementationOnce(() => firstClient);
|
|
125
|
+
LspClient.mockImplementationOnce(() => secondClient);
|
|
126
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
127
|
+
await manager.start();
|
|
128
|
+
await jest.advanceTimersByTimeAsync(30000);
|
|
129
|
+
expect(firstClient.request).toHaveBeenCalledWith('workspace/symbol', { query: '__lsp_mcp_healthcheck__' }, 5000);
|
|
130
|
+
expect(secondClient.start).toHaveBeenCalled();
|
|
131
|
+
expect(manager.getClient('typescript')).toBe(secondClient);
|
|
132
|
+
await manager.shutdown();
|
|
133
|
+
});
|
|
134
|
+
it('attempts installation when no server is available and starts the installed candidate', async () => {
|
|
135
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
136
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
137
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
138
|
+
const installLsp = jest.requireMock('../installer').installLsp;
|
|
139
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
140
|
+
const candidate = { cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' };
|
|
141
|
+
detectLanguages.mockResolvedValue([{ language: 'python', confidence: 'marker', markers: ['pyproject.toml'] }]);
|
|
142
|
+
findAvailableLsp.mockResolvedValue(null);
|
|
143
|
+
getLspCandidates.mockReturnValue([candidate]);
|
|
144
|
+
installLsp.mockResolvedValue({ success: true });
|
|
145
|
+
LspClient.mockImplementation(() => new MockLspClient());
|
|
146
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'verbose');
|
|
147
|
+
await manager.start();
|
|
148
|
+
expect(installLsp).toHaveBeenCalledWith(candidate);
|
|
149
|
+
expect(LspClient).toHaveBeenCalledWith(candidate, '/workspace/project', 'info');
|
|
150
|
+
await manager.shutdown();
|
|
151
|
+
});
|
|
152
|
+
it('surfaces client start failures in health status', async () => {
|
|
153
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
154
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
155
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
156
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
157
|
+
const failingClient = new MockLspClient();
|
|
158
|
+
failingClient.start.mockRejectedValue(new Error('startup failed'));
|
|
159
|
+
detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
|
|
160
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
161
|
+
getLspCandidates.mockReturnValue([]);
|
|
162
|
+
LspClient.mockImplementation(() => failingClient);
|
|
163
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
164
|
+
await manager.start();
|
|
165
|
+
expect(manager.getHealth()).toEqual([
|
|
166
|
+
{
|
|
167
|
+
language: 'typescript',
|
|
168
|
+
status: 'error',
|
|
169
|
+
error: 'startup failed',
|
|
170
|
+
capabilities: undefined
|
|
171
|
+
}
|
|
172
|
+
]);
|
|
173
|
+
await manager.shutdown();
|
|
174
|
+
});
|
|
175
|
+
it('reports shutdown failures in the log', async () => {
|
|
176
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
177
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
178
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
179
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
180
|
+
const failingShutdownClient = new MockLspClient();
|
|
181
|
+
failingShutdownClient.shutdown.mockRejectedValue(new Error('shutdown failed'));
|
|
182
|
+
detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
|
|
183
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
184
|
+
getLspCandidates.mockReturnValue([]);
|
|
185
|
+
LspClient.mockImplementation(() => failingShutdownClient);
|
|
186
|
+
const stderrWrite = jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
187
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
188
|
+
await manager.start();
|
|
189
|
+
await manager.shutdown();
|
|
190
|
+
expect(stderrWrite).toHaveBeenLastCalledWith(expect.stringContaining('Shutdown: 0 LSP-Server beendet, 1 Fehler'));
|
|
191
|
+
stderrWrite.mockRestore();
|
|
192
|
+
});
|
|
193
|
+
it('marks a language as error when no candidate exists to install', async () => {
|
|
194
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
195
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
196
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
197
|
+
detectLanguages.mockResolvedValue([{ language: 'unknown-language', confidence: 'extension', markers: ['main.unknown'] }]);
|
|
198
|
+
findAvailableLsp.mockResolvedValue(null);
|
|
199
|
+
getLspCandidates.mockReturnValue([]);
|
|
200
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
201
|
+
await manager.start();
|
|
202
|
+
expect(manager.getHealth()).toEqual([
|
|
203
|
+
{
|
|
204
|
+
language: 'unknown-language',
|
|
205
|
+
status: 'error',
|
|
206
|
+
error: 'No LSP server available for unknown-language',
|
|
207
|
+
capabilities: undefined
|
|
208
|
+
}
|
|
209
|
+
]);
|
|
210
|
+
await manager.shutdown();
|
|
211
|
+
});
|
|
212
|
+
it('stops restarting once the max restart count is reached', async () => {
|
|
213
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
214
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
215
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
216
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
217
|
+
const client = new MockLspClient();
|
|
218
|
+
detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
|
|
219
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
220
|
+
getLspCandidates.mockReturnValue([]);
|
|
221
|
+
LspClient.mockImplementation(() => client);
|
|
222
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
223
|
+
await manager.start();
|
|
224
|
+
const managerAccess = manager;
|
|
225
|
+
const state = managerAccess.states.get('typescript');
|
|
226
|
+
expect(state).toBeDefined();
|
|
227
|
+
if (!state) {
|
|
228
|
+
throw new Error('Expected typescript state');
|
|
229
|
+
}
|
|
230
|
+
state.restartCount = 3;
|
|
231
|
+
await managerAccess.restartLanguage(state, 'too many restarts');
|
|
232
|
+
expect(manager.getClient('typescript')).toBeNull();
|
|
233
|
+
expect(manager.getHealth()).toEqual([
|
|
234
|
+
{
|
|
235
|
+
language: 'typescript',
|
|
236
|
+
status: 'error',
|
|
237
|
+
error: 'too many restarts',
|
|
238
|
+
capabilities: { hoverProvider: true }
|
|
239
|
+
}
|
|
240
|
+
]);
|
|
241
|
+
await manager.shutdown();
|
|
242
|
+
});
|
|
243
|
+
it('stores published diagnostics and exposes ready clients', async () => {
|
|
244
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
245
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
246
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
247
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
248
|
+
const client = new MockLspClient();
|
|
249
|
+
detectLanguages.mockResolvedValue([{ language: 'typescript', confidence: 'marker', markers: ['tsconfig.json'] }]);
|
|
250
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'typescript-language-server', args: ['--stdio'], pkg: 'typescript-language-server', mgr: 'npm' });
|
|
251
|
+
getLspCandidates.mockReturnValue([]);
|
|
252
|
+
LspClient.mockImplementation(() => client);
|
|
253
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
254
|
+
await manager.start();
|
|
255
|
+
client.emit('notification', 'textDocument/publishDiagnostics', {
|
|
256
|
+
uri: 'file:///workspace/project/src/index.ts',
|
|
257
|
+
diagnostics: [{ message: 'Boom', severity: 1, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]
|
|
258
|
+
});
|
|
259
|
+
expect(manager.getReadyClients()).toEqual([client]);
|
|
260
|
+
expect(manager.getFileDiagnostics('/workspace/project/src/index.ts')).toEqual([
|
|
261
|
+
{
|
|
262
|
+
uri: 'file:///workspace/project/src/index.ts',
|
|
263
|
+
message: 'Boom',
|
|
264
|
+
severity: 1,
|
|
265
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
|
|
266
|
+
}
|
|
267
|
+
]);
|
|
268
|
+
expect(manager.getWorkspaceDiagnostics('typescript')).toEqual([
|
|
269
|
+
{
|
|
270
|
+
uri: 'file:///workspace/project/src/index.ts',
|
|
271
|
+
message: 'Boom',
|
|
272
|
+
severity: 1,
|
|
273
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
|
|
274
|
+
}
|
|
275
|
+
]);
|
|
276
|
+
await manager.shutdown();
|
|
277
|
+
});
|
|
278
|
+
it('handles empty detections and aggregates workspace diagnostics without filters', async () => {
|
|
279
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
280
|
+
detectLanguages.mockResolvedValue([]);
|
|
281
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
282
|
+
await manager.start();
|
|
283
|
+
expect(manager.getHealth()).toEqual([]);
|
|
284
|
+
expect(manager.getReadyClients()).toEqual([]);
|
|
285
|
+
expect(manager.getWorkspaceDiagnostics()).toEqual([]);
|
|
286
|
+
await manager.shutdown();
|
|
287
|
+
});
|
|
288
|
+
});
|