@theupsider/lsp-mcp 1.2.0 → 1.3.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/dist/detection/__tests__/lsp-mapping.test.js +7 -0
- package/dist/detection/lsp-mapping.js +19 -1
- package/dist/lsp/__tests__/diagnostic-store.test.js +29 -11
- package/dist/lsp/__tests__/lifecycle-manager.test.js +46 -1
- package/dist/lsp/diagnostic-store.js +21 -6
- package/dist/lsp/lifecycle-manager.js +58 -12
- package/dist/lsp/lsp-client.js +3 -1
- package/dist/lsp/server-supervisor.js +9 -3
- package/dist/mcp/__tests__/read-tools.test.js +1 -0
- package/dist/mcp/__tests__/write-tools.test.js +2 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/read-tools.js +6 -3
- package/package.json +1 -1
|
@@ -14,6 +14,12 @@ describe('lsp-mapping', () => {
|
|
|
14
14
|
{ cmd: 'pylsp', args: [], pkg: 'python-lsp-server', mgr: 'pip' }
|
|
15
15
|
]);
|
|
16
16
|
});
|
|
17
|
+
it('returns ruff as the python linter candidate, run alongside the primary server', () => {
|
|
18
|
+
expect((0, lsp_mapping_1.getLinterCandidates)('python')).toEqual([
|
|
19
|
+
{ cmd: 'ruff', args: ['server', '--quiet'], pkg: 'ruff', mgr: 'pip' }
|
|
20
|
+
]);
|
|
21
|
+
expect((0, lsp_mapping_1.getLinterCandidates)('typescript')).toEqual([]);
|
|
22
|
+
});
|
|
17
23
|
it('returns the full supported language list and shared candidate mappings', () => {
|
|
18
24
|
expect(lsp_mapping_1.SUPPORTED_LANGUAGES).toEqual([
|
|
19
25
|
'python',
|
|
@@ -69,6 +75,7 @@ describe('lsp-mapping', () => {
|
|
|
69
75
|
process.env.PATH = binDir;
|
|
70
76
|
await expect((0, lsp_mapping_1.findAvailableLsp)('swift')).resolves.toBeNull();
|
|
71
77
|
await expect((0, lsp_mapping_1.findAvailableLsp)('unknown')).resolves.toBeNull();
|
|
78
|
+
await expect((0, lsp_mapping_1.findAvailableLinter)('python')).resolves.toBeNull();
|
|
72
79
|
}
|
|
73
80
|
finally {
|
|
74
81
|
process.env.PATH = originalPath;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SUPPORTED_LANGUAGES = void 0;
|
|
4
4
|
exports.getLspCandidates = getLspCandidates;
|
|
5
|
+
exports.getLinterCandidates = getLinterCandidates;
|
|
5
6
|
exports.findAvailableLsp = findAvailableLsp;
|
|
7
|
+
exports.findAvailableLinter = findAvailableLinter;
|
|
6
8
|
const node_child_process_1 = require("node:child_process");
|
|
7
9
|
const node_util_1 = require("node:util");
|
|
8
10
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
@@ -12,6 +14,14 @@ const TYPESCRIPT_CANDIDATES = [
|
|
|
12
14
|
const CLANGD_CANDIDATES = [
|
|
13
15
|
{ cmd: 'clangd', args: [], pkg: 'clangd', mgr: 'apt' }
|
|
14
16
|
];
|
|
17
|
+
// Linters that run alongside (not instead of) the primary language server for a
|
|
18
|
+
// language, so their diagnostics (e.g. ruff lint rules) show up next to the
|
|
19
|
+
// primary server's diagnostics (e.g. pyright type errors) instead of being lost.
|
|
20
|
+
const LANGUAGE_TO_LINTERS = {
|
|
21
|
+
python: [
|
|
22
|
+
{ cmd: 'ruff', args: ['server', '--quiet'], pkg: 'ruff', mgr: 'pip' }
|
|
23
|
+
]
|
|
24
|
+
};
|
|
15
25
|
const LANGUAGE_TO_CANDIDATES = {
|
|
16
26
|
python: [
|
|
17
27
|
{ cmd: 'pyright-langserver', args: ['--stdio'], pkg: 'pyright', mgr: 'npm' },
|
|
@@ -65,8 +75,16 @@ exports.SUPPORTED_LANGUAGES = Object.freeze([
|
|
|
65
75
|
function getLspCandidates(language) {
|
|
66
76
|
return LANGUAGE_TO_CANDIDATES[language]?.map((candidate) => ({ ...candidate, args: [...candidate.args] })) ?? [];
|
|
67
77
|
}
|
|
78
|
+
function getLinterCandidates(language) {
|
|
79
|
+
return LANGUAGE_TO_LINTERS[language]?.map((candidate) => ({ ...candidate, args: [...candidate.args] })) ?? [];
|
|
80
|
+
}
|
|
68
81
|
async function findAvailableLsp(language) {
|
|
69
|
-
|
|
82
|
+
return await findFirstAvailable(getLspCandidates(language));
|
|
83
|
+
}
|
|
84
|
+
async function findAvailableLinter(language) {
|
|
85
|
+
return await findFirstAvailable(getLinterCandidates(language));
|
|
86
|
+
}
|
|
87
|
+
async function findFirstAvailable(candidates) {
|
|
70
88
|
for (const candidate of candidates) {
|
|
71
89
|
if (await commandExists(candidate.cmd)) {
|
|
72
90
|
return candidate;
|
|
@@ -2,32 +2,50 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const diagnostic_store_1 = require("../diagnostic-store");
|
|
4
4
|
describe('DiagnosticStore', () => {
|
|
5
|
-
it(
|
|
5
|
+
it("replaces a source's diagnostics wholesale per uri on each publish", () => {
|
|
6
6
|
const store = new diagnostic_store_1.DiagnosticStore();
|
|
7
|
-
store.store({ uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'first')] });
|
|
8
|
-
store.store({ uri: 'file:///a.ts', diagnostics: [makeDiagnostic(2, 'second')] });
|
|
7
|
+
store.store('pyright', { uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'first')] });
|
|
8
|
+
store.store('pyright', { uri: 'file:///a.ts', diagnostics: [makeDiagnostic(2, 'second')] });
|
|
9
9
|
expect(store.getForFile('/a.ts')).toEqual([
|
|
10
10
|
{ ...makeDiagnostic(2, 'second'), uri: 'file:///a.ts' },
|
|
11
11
|
]);
|
|
12
12
|
});
|
|
13
|
+
it('merges diagnostics from multiple sources for the same uri instead of overwriting', () => {
|
|
14
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
15
|
+
store.store('pyright', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(1, 'type error')] });
|
|
16
|
+
store.store('ruff', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(2, 'unused import')] });
|
|
17
|
+
expect(store.getForFile('/a.py')).toEqual([
|
|
18
|
+
{ ...makeDiagnostic(1, 'type error'), uri: 'file:///a.py' },
|
|
19
|
+
{ ...makeDiagnostic(2, 'unused import'), uri: 'file:///a.py' },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
it("only replaces the publishing source's diagnostics, leaving other sources intact", () => {
|
|
23
|
+
const store = new diagnostic_store_1.DiagnosticStore();
|
|
24
|
+
store.store('pyright', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(1, 'type error')] });
|
|
25
|
+
store.store('ruff', { uri: 'file:///a.py', diagnostics: [makeDiagnostic(2, 'unused import')] });
|
|
26
|
+
store.store('ruff', { uri: 'file:///a.py', diagnostics: [] });
|
|
27
|
+
expect(store.getForFile('/a.py')).toEqual([
|
|
28
|
+
{ ...makeDiagnostic(1, 'type error'), uri: 'file:///a.py' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
13
31
|
it('ignores malformed publishDiagnostics payloads', () => {
|
|
14
32
|
const store = new diagnostic_store_1.DiagnosticStore();
|
|
15
|
-
store.store(null);
|
|
16
|
-
store.store({ uri: 'file:///a.ts' });
|
|
17
|
-
store.store({ diagnostics: [] });
|
|
33
|
+
store.store('pyright', null);
|
|
34
|
+
store.store('pyright', { uri: 'file:///a.ts' });
|
|
35
|
+
store.store('pyright', { diagnostics: [] });
|
|
18
36
|
expect(store.getForFile('/a.ts')).toEqual([]);
|
|
19
37
|
});
|
|
20
38
|
it('sorts workspace diagnostics by severity ascending (errors before warnings)', () => {
|
|
21
39
|
const store = new diagnostic_store_1.DiagnosticStore();
|
|
22
|
-
store.store({
|
|
40
|
+
store.store('pyright', {
|
|
23
41
|
uri: 'file:///warn.ts',
|
|
24
42
|
diagnostics: [makeDiagnostic(2, 'a warning')],
|
|
25
43
|
});
|
|
26
|
-
store.store({
|
|
44
|
+
store.store('pyright', {
|
|
27
45
|
uri: 'file:///error.ts',
|
|
28
46
|
diagnostics: [makeDiagnostic(1, 'an error')],
|
|
29
47
|
});
|
|
30
|
-
store.store({
|
|
48
|
+
store.store('pyright', {
|
|
31
49
|
uri: 'file:///hint.ts',
|
|
32
50
|
diagnostics: [makeDiagnostic(4, 'a hint')],
|
|
33
51
|
});
|
|
@@ -40,8 +58,8 @@ describe('DiagnosticStore', () => {
|
|
|
40
58
|
});
|
|
41
59
|
it('filters workspace diagnostics by language extension', () => {
|
|
42
60
|
const store = new diagnostic_store_1.DiagnosticStore();
|
|
43
|
-
store.store({ uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'ts issue')] });
|
|
44
|
-
store.store({ uri: 'file:///b.py', diagnostics: [makeDiagnostic(1, 'py issue')] });
|
|
61
|
+
store.store('pyright', { uri: 'file:///a.ts', diagnostics: [makeDiagnostic(1, 'ts issue')] });
|
|
62
|
+
store.store('pyright', { uri: 'file:///b.py', diagnostics: [makeDiagnostic(1, 'py issue')] });
|
|
45
63
|
const result = store.getForWorkspace('python');
|
|
46
64
|
expect(result).toEqual([
|
|
47
65
|
{ ...makeDiagnostic(1, 'py issue'), uri: 'file:///b.py' },
|
|
@@ -7,7 +7,9 @@ jest.mock('../../detection/language-detector', () => ({
|
|
|
7
7
|
}));
|
|
8
8
|
jest.mock('../../detection/lsp-mapping', () => ({
|
|
9
9
|
findAvailableLsp: jest.fn(),
|
|
10
|
-
getLspCandidates: jest.fn()
|
|
10
|
+
getLspCandidates: jest.fn(),
|
|
11
|
+
findAvailableLinter: jest.fn(),
|
|
12
|
+
getLinterCandidates: jest.fn().mockReturnValue([])
|
|
11
13
|
}));
|
|
12
14
|
jest.mock('../installer', () => ({
|
|
13
15
|
installLsp: jest.fn()
|
|
@@ -287,4 +289,47 @@ describe('LifecycleManager', () => {
|
|
|
287
289
|
expect(manager.getWorkspaceDiagnostics()).toEqual([]);
|
|
288
290
|
await manager.shutdown();
|
|
289
291
|
});
|
|
292
|
+
it('merges diagnostics from a linter server alongside the primary server for the same language', async () => {
|
|
293
|
+
const detectLanguages = jest.requireMock('../../detection/language-detector').detectLanguages;
|
|
294
|
+
const findAvailableLsp = jest.requireMock('../../detection/lsp-mapping').findAvailableLsp;
|
|
295
|
+
const getLspCandidates = jest.requireMock('../../detection/lsp-mapping').getLspCandidates;
|
|
296
|
+
const findAvailableLinter = jest.requireMock('../../detection/lsp-mapping').findAvailableLinter;
|
|
297
|
+
const getLinterCandidates = jest.requireMock('../../detection/lsp-mapping').getLinterCandidates;
|
|
298
|
+
const LspClient = jest.requireMock('../lsp-client').LspClient;
|
|
299
|
+
const ruffCandidate = { cmd: 'ruff', args: ['server', '--quiet'], pkg: 'ruff', mgr: 'pip' };
|
|
300
|
+
const pyrightClient = new MockLspClient();
|
|
301
|
+
const ruffClient = new MockLspClient();
|
|
302
|
+
detectLanguages.mockResolvedValue([{ language: 'python', confidence: 'marker', markers: ['pyproject.toml'] }]);
|
|
303
|
+
findAvailableLsp.mockResolvedValue({ cmd: 'pyright-langserver', args: ['--stdio'], pkg: 'pyright', mgr: 'npm' });
|
|
304
|
+
getLspCandidates.mockReturnValue([]);
|
|
305
|
+
getLinterCandidates.mockReturnValue([ruffCandidate]);
|
|
306
|
+
findAvailableLinter.mockResolvedValue(ruffCandidate);
|
|
307
|
+
LspClient.mockImplementationOnce(() => pyrightClient);
|
|
308
|
+
LspClient.mockImplementationOnce(() => ruffClient);
|
|
309
|
+
const manager = new lifecycle_manager_1.LifecycleManager('/workspace/project', 'debug');
|
|
310
|
+
await manager.start();
|
|
311
|
+
pyrightClient.emit('notification', 'textDocument/publishDiagnostics', {
|
|
312
|
+
uri: 'file:///workspace/project/app.py',
|
|
313
|
+
diagnostics: [{ message: 'Incompatible type', severity: 1, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }]
|
|
314
|
+
});
|
|
315
|
+
ruffClient.emit('notification', 'textDocument/publishDiagnostics', {
|
|
316
|
+
uri: 'file:///workspace/project/app.py',
|
|
317
|
+
diagnostics: [{ message: 'F401 unused import', severity: 2, range: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } } }]
|
|
318
|
+
});
|
|
319
|
+
expect(manager.getFileDiagnostics('/workspace/project/app.py').map((d) => d.message)).toEqual([
|
|
320
|
+
'Incompatible type',
|
|
321
|
+
'F401 unused import'
|
|
322
|
+
]);
|
|
323
|
+
// Navigation/rename/format stay pinned to the primary server, not the linter.
|
|
324
|
+
expect(manager.getClient('python')).toBe(pyrightClient);
|
|
325
|
+
expect(manager.getReadyClients('python')).toEqual([pyrightClient]);
|
|
326
|
+
// Both servers are started and both surface in health.
|
|
327
|
+
expect(manager.getHealth()).toEqual([
|
|
328
|
+
{ language: 'python', status: 'ready', capabilities: { hoverProvider: true } },
|
|
329
|
+
{ language: 'python', status: 'ready', capabilities: { hoverProvider: true } }
|
|
330
|
+
]);
|
|
331
|
+
// File-scope diagnostics touch every diagnostic-contributing server.
|
|
332
|
+
expect(manager.getDiagnosticClientsForFile('/workspace/project/app.py')).toEqual([pyrightClient, ruffClient]);
|
|
333
|
+
await manager.shutdown();
|
|
334
|
+
});
|
|
290
335
|
});
|
|
@@ -8,29 +8,44 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
8
8
|
const language_registry_1 = require("../detection/language-registry");
|
|
9
9
|
const uri_1 = require("../utils/uri");
|
|
10
10
|
class DiagnosticStore {
|
|
11
|
+
// uri -> sourceId -> diagnostics. Keying by source lets multiple servers for the
|
|
12
|
+
// same language (e.g. pyright's type checks and ruff's lint rules) publish
|
|
13
|
+
// diagnostics for the same file without one server's publish overwriting another's.
|
|
11
14
|
diagnostics = new Map();
|
|
12
|
-
store(params) {
|
|
15
|
+
store(sourceId, params) {
|
|
13
16
|
if (!isPublishDiagnosticsParams(params)) {
|
|
14
17
|
return;
|
|
15
18
|
}
|
|
16
|
-
this.diagnostics.
|
|
19
|
+
let bySource = this.diagnostics.get(params.uri);
|
|
20
|
+
if (!bySource) {
|
|
21
|
+
bySource = new Map();
|
|
22
|
+
this.diagnostics.set(params.uri, bySource);
|
|
23
|
+
}
|
|
24
|
+
bySource.set(sourceId, cloneDiagnostics(params.diagnostics));
|
|
17
25
|
}
|
|
18
26
|
getForFile(filePath) {
|
|
19
27
|
const uri = (0, uri_1.pathToUri)(filePath);
|
|
20
|
-
return
|
|
28
|
+
return this.flatten(uri);
|
|
21
29
|
}
|
|
22
30
|
getForWorkspace(language) {
|
|
23
31
|
const allowedExtensions = language ? (0, language_registry_1.extensionsForLanguage)(language) : null;
|
|
24
|
-
return Array.from(this.diagnostics.
|
|
25
|
-
.filter((
|
|
32
|
+
return Array.from(this.diagnostics.keys())
|
|
33
|
+
.filter((uri) => {
|
|
26
34
|
if (!allowedExtensions) {
|
|
27
35
|
return true;
|
|
28
36
|
}
|
|
29
37
|
return allowedExtensions.includes(node_path_1.default.extname((0, uri_1.uriToPath)(uri)).toLowerCase());
|
|
30
38
|
})
|
|
31
|
-
.flatMap((
|
|
39
|
+
.flatMap((uri) => this.flatten(uri))
|
|
32
40
|
.sort((left, right) => (left.severity ?? 4) - (right.severity ?? 4));
|
|
33
41
|
}
|
|
42
|
+
flatten(uri) {
|
|
43
|
+
const bySource = this.diagnostics.get(uri);
|
|
44
|
+
if (!bySource) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return Array.from(bySource.values()).flatMap((diagnostics) => diagnostics.map((diagnostic) => ({ ...diagnostic, uri })));
|
|
48
|
+
}
|
|
34
49
|
}
|
|
35
50
|
exports.DiagnosticStore = DiagnosticStore;
|
|
36
51
|
function isPublishDiagnosticsParams(value) {
|
|
@@ -8,12 +8,18 @@ exports.promiseWithTimeout = promiseWithTimeout;
|
|
|
8
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
9
|
const language_detector_1 = require("../detection/language-detector");
|
|
10
10
|
const language_registry_1 = require("../detection/language-registry");
|
|
11
|
+
const lsp_mapping_1 = require("../detection/lsp-mapping");
|
|
11
12
|
const diagnostic_store_1 = require("./diagnostic-store");
|
|
12
13
|
const server_supervisor_1 = require("./server-supervisor");
|
|
13
14
|
class LifecycleManager {
|
|
14
15
|
projectRoot;
|
|
15
16
|
logLevel;
|
|
16
17
|
supervisors = new Map();
|
|
18
|
+
// Secondary servers that run alongside the primary one for a language purely to
|
|
19
|
+
// contribute diagnostics (e.g. ruff's lint rules alongside pyright's type checks).
|
|
20
|
+
// They are never returned by getClient/getReadyClients, which stay scoped to the
|
|
21
|
+
// primary server so navigation/formatting/rename requests keep going to one place.
|
|
22
|
+
linterSupervisors = new Map();
|
|
17
23
|
store = new diagnostic_store_1.DiagnosticStore();
|
|
18
24
|
constructor(projectRoot, logLevel) {
|
|
19
25
|
this.projectRoot = projectRoot;
|
|
@@ -24,12 +30,12 @@ class LifecycleManager {
|
|
|
24
30
|
await promiseWithTimeout(startPromise, 30000, "Lifecycle start timed out");
|
|
25
31
|
}
|
|
26
32
|
async ensureLanguage(language) {
|
|
27
|
-
if (this.supervisors.has(language)) {
|
|
28
|
-
|
|
33
|
+
if (!this.supervisors.has(language)) {
|
|
34
|
+
const supervisor = this.createSupervisor(language);
|
|
35
|
+
this.supervisors.set(language, supervisor);
|
|
36
|
+
await supervisor.start();
|
|
29
37
|
}
|
|
30
|
-
|
|
31
|
-
this.supervisors.set(language, supervisor);
|
|
32
|
-
await supervisor.start();
|
|
38
|
+
await this.ensureLinter(language);
|
|
33
39
|
}
|
|
34
40
|
async ensureLanguageForFile(filePath) {
|
|
35
41
|
const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
|
|
@@ -47,8 +53,26 @@ class LifecycleManager {
|
|
|
47
53
|
}
|
|
48
54
|
return null;
|
|
49
55
|
}
|
|
56
|
+
// All ready clients contributing diagnostics for a file's language: the primary
|
|
57
|
+
// language server plus any linter servers (e.g. ruff for python). Used so a single
|
|
58
|
+
// lsp_diagnostics(file) call opens/saves the file on every diagnostic source instead
|
|
59
|
+
// of only the primary one.
|
|
60
|
+
getDiagnosticClientsForFile(filePath) {
|
|
61
|
+
const language = (0, language_registry_1.extensionToLanguage)(node_path_1.default.extname(filePath));
|
|
62
|
+
if (!language) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [this.supervisors.get(language), this.linterSupervisors.get(language)]
|
|
66
|
+
.filter((supervisor) => {
|
|
67
|
+
return !!supervisor && supervisor.getHealth().status === "ready";
|
|
68
|
+
})
|
|
69
|
+
.flatMap((supervisor) => {
|
|
70
|
+
const client = supervisor.getClient();
|
|
71
|
+
return client ? [client] : [];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
50
74
|
getHealth() {
|
|
51
|
-
return
|
|
75
|
+
return this.allSupervisors().map((supervisor) => supervisor.getHealth());
|
|
52
76
|
}
|
|
53
77
|
getReadyClients(language) {
|
|
54
78
|
return Array.from(this.supervisors.values())
|
|
@@ -69,7 +93,7 @@ class LifecycleManager {
|
|
|
69
93
|
return this.store.getForWorkspace(language);
|
|
70
94
|
}
|
|
71
95
|
async analyzeWorkspace(language) {
|
|
72
|
-
const results = await Promise.allSettled(
|
|
96
|
+
const results = await Promise.allSettled(this.allSupervisorEntries()
|
|
73
97
|
.filter(([lang, supervisor]) => {
|
|
74
98
|
const health = supervisor.getHealth();
|
|
75
99
|
return health.status === "ready" && (!language || lang === language);
|
|
@@ -109,7 +133,7 @@ class LifecycleManager {
|
|
|
109
133
|
};
|
|
110
134
|
}
|
|
111
135
|
async ensureSeedFilesOpen() {
|
|
112
|
-
await Promise.all(
|
|
136
|
+
await Promise.all(this.allSupervisorEntries().map(async ([language, supervisor]) => {
|
|
113
137
|
const client = supervisor.getClient();
|
|
114
138
|
if (!client)
|
|
115
139
|
return;
|
|
@@ -118,7 +142,7 @@ class LifecycleManager {
|
|
|
118
142
|
}));
|
|
119
143
|
}
|
|
120
144
|
async shutdown() {
|
|
121
|
-
const supervisors =
|
|
145
|
+
const supervisors = this.allSupervisors();
|
|
122
146
|
const results = await Promise.allSettled(supervisors.map(async (supervisor) => await promiseWithTimeout(supervisor.shutdown(), 5000, "LSP shutdown timed out")));
|
|
123
147
|
const errors = results.filter((result) => result.status === "rejected").length;
|
|
124
148
|
process.stderr.write(`{"timestamp":"${new Date().toISOString()}","level":"info","event":"Shutdown: ${supervisors.length - errors} LSP-Server beendet, ${errors} Fehler"}\n`);
|
|
@@ -131,14 +155,36 @@ class LifecycleManager {
|
|
|
131
155
|
const supervisor = this.createSupervisor(entry.language);
|
|
132
156
|
this.supervisors.set(entry.language, supervisor);
|
|
133
157
|
await supervisor.start();
|
|
158
|
+
await this.ensureLinter(entry.language);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async ensureLinter(language) {
|
|
162
|
+
if (this.linterSupervisors.has(language)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if ((0, lsp_mapping_1.getLinterCandidates)(language).length === 0) {
|
|
166
|
+
return;
|
|
134
167
|
}
|
|
168
|
+
const supervisor = this.createSupervisor(language, true);
|
|
169
|
+
this.linterSupervisors.set(language, supervisor);
|
|
170
|
+
await supervisor.start();
|
|
171
|
+
}
|
|
172
|
+
allSupervisors() {
|
|
173
|
+
return this.allSupervisorEntries().map(([, supervisor]) => supervisor);
|
|
135
174
|
}
|
|
136
|
-
|
|
175
|
+
allSupervisorEntries() {
|
|
176
|
+
return [
|
|
177
|
+
...Array.from(this.supervisors.entries()),
|
|
178
|
+
...Array.from(this.linterSupervisors.entries()),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
createSupervisor(language, linter = false) {
|
|
182
|
+
const sourceId = linter ? `${language}:lint` : language;
|
|
137
183
|
return new server_supervisor_1.ServerSupervisor(language, this.projectRoot, this.logLevel, (method, params) => {
|
|
138
184
|
if (method === "textDocument/publishDiagnostics") {
|
|
139
|
-
this.store.store(params);
|
|
185
|
+
this.store.store(sourceId, params);
|
|
140
186
|
}
|
|
141
|
-
});
|
|
187
|
+
}, linter);
|
|
142
188
|
}
|
|
143
189
|
}
|
|
144
190
|
exports.LifecycleManager = LifecycleManager;
|
package/dist/lsp/lsp-client.js
CHANGED
|
@@ -117,7 +117,9 @@ class LspClient extends node_events_1.EventEmitter {
|
|
|
117
117
|
this.ready = false;
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
// The LSP spec defines shutdown as taking no params; strict servers (e.g. ruff,
|
|
121
|
+
// written in Rust with serde) reject an empty object `{}` as "expected unit".
|
|
122
|
+
await this.request("shutdown", null, 5000);
|
|
121
123
|
this.notify("exit", {});
|
|
122
124
|
this.ready = false;
|
|
123
125
|
const currentProcess = this.process;
|
|
@@ -15,12 +15,14 @@ class ServerSupervisor {
|
|
|
15
15
|
projectRoot;
|
|
16
16
|
logLevel;
|
|
17
17
|
onDiagnostics;
|
|
18
|
+
linter;
|
|
18
19
|
shuttingDown = false;
|
|
19
|
-
constructor(language, projectRoot, logLevel, onDiagnostics) {
|
|
20
|
+
constructor(language, projectRoot, logLevel, onDiagnostics, linter = false) {
|
|
20
21
|
this.language = language;
|
|
21
22
|
this.projectRoot = projectRoot;
|
|
22
23
|
this.logLevel = logLevel;
|
|
23
24
|
this.onDiagnostics = onDiagnostics;
|
|
25
|
+
this.linter = linter;
|
|
24
26
|
}
|
|
25
27
|
async start() {
|
|
26
28
|
const serverDef = await this.resolveServer();
|
|
@@ -121,11 +123,15 @@ class ServerSupervisor {
|
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
async resolveServer() {
|
|
124
|
-
const available =
|
|
126
|
+
const available = this.linter
|
|
127
|
+
? await (0, lsp_mapping_1.findAvailableLinter)(this.language)
|
|
128
|
+
: await (0, lsp_mapping_1.findAvailableLsp)(this.language);
|
|
125
129
|
if (available) {
|
|
126
130
|
return available;
|
|
127
131
|
}
|
|
128
|
-
const candidate = (
|
|
132
|
+
const candidate = (this.linter
|
|
133
|
+
? (0, lsp_mapping_1.getLinterCandidates)(this.language)
|
|
134
|
+
: (0, lsp_mapping_1.getLspCandidates)(this.language))[0] ?? null;
|
|
129
135
|
if (!candidate) {
|
|
130
136
|
return null;
|
|
131
137
|
}
|
|
@@ -523,6 +523,7 @@ function getHandler(registrar, name) {
|
|
|
523
523
|
function createLifecycle(options) {
|
|
524
524
|
return {
|
|
525
525
|
getClientForFile: jest.fn((_) => options.fileClient ?? null),
|
|
526
|
+
getDiagnosticClientsForFile: jest.fn((_) => options.fileClient ? [options.fileClient] : []),
|
|
526
527
|
getReadyClients: jest.fn((_) => options.workspaceClients ?? []),
|
|
527
528
|
getFileDiagnostics: jest.fn((_) => (options.diagnostics ?? []).filter((diagnostic) => diagnostic.uri === "file:///workspace/src/index.ts")),
|
|
528
529
|
getWorkspaceDiagnostics: jest.fn((_) => options.diagnostics ?? []),
|
|
@@ -213,6 +213,7 @@ describe("registerWriteTools", () => {
|
|
|
213
213
|
const registrar = new FakeRegistrar();
|
|
214
214
|
const noClientLifecycle = {
|
|
215
215
|
getClientForFile: jest.fn(() => null),
|
|
216
|
+
getDiagnosticClientsForFile: jest.fn(() => []),
|
|
216
217
|
getReadyClients: jest.fn(() => []),
|
|
217
218
|
getFileDiagnostics: jest.fn((_) => []),
|
|
218
219
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
|
@@ -296,6 +297,7 @@ function getHandler(registrar, name) {
|
|
|
296
297
|
function createLifecycle(client) {
|
|
297
298
|
return {
|
|
298
299
|
getClientForFile: jest.fn((_) => client),
|
|
300
|
+
getDiagnosticClientsForFile: jest.fn((_) => [client]),
|
|
299
301
|
getReadyClients: jest.fn(() => [client]),
|
|
300
302
|
getFileDiagnostics: jest.fn((_) => []),
|
|
301
303
|
getWorkspaceDiagnostics: jest.fn(() => []),
|
package/dist/mcp/server.js
CHANGED
|
@@ -190,6 +190,7 @@ class McpServer {
|
|
|
190
190
|
createLifecycleProxy() {
|
|
191
191
|
return {
|
|
192
192
|
getClientForFile: (filePath) => this.requireManager().getClientForFile(filePath),
|
|
193
|
+
getDiagnosticClientsForFile: (filePath) => this.requireManager().getDiagnosticClientsForFile(filePath),
|
|
193
194
|
getReadyClients: (language) => this.requireManager().getReadyClients(language),
|
|
194
195
|
getFileDiagnostics: (filePath) => this.requireManager().getFileDiagnostics(filePath),
|
|
195
196
|
getWorkspaceDiagnostics: (language) => this.requireManager().getWorkspaceDiagnostics(language),
|
|
@@ -91,15 +91,18 @@ function registerReadTools(registrar, lifecycleManager, options) {
|
|
|
91
91
|
try {
|
|
92
92
|
if (filePath) {
|
|
93
93
|
await lifecycleManager.ensureLanguageForFile(filePath);
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// Trigger analysis on every diagnostic source for this file's language
|
|
95
|
+
// (e.g. both pyright and ruff for python), not just the primary server,
|
|
96
|
+
// so the merged result includes type errors and lint findings alike.
|
|
97
|
+
const clients = lifecycleManager.getDiagnosticClientsForFile(filePath);
|
|
98
|
+
await Promise.all(clients.map(async (client) => {
|
|
96
99
|
const waitPromise = client.waitForDiagnosticsPublish(filePath, 10000);
|
|
97
100
|
await client.ensureDidOpen(filePath);
|
|
98
101
|
client.notify("textDocument/didSave", {
|
|
99
102
|
textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
|
|
100
103
|
});
|
|
101
104
|
await waitPromise;
|
|
102
|
-
}
|
|
105
|
+
}));
|
|
103
106
|
}
|
|
104
107
|
if (scope === "workspace") {
|
|
105
108
|
const language = typeof args.language === "string" ? args.language : undefined;
|