Skip to content

Commit 38b5de8

Browse files
Aayush-engineerEhindero Israel
andauthored
fix(workspace): normalize file URI keys in LSP diagnostics map to fix Windows mismatch (#17816)
Co-authored-by: Ehindero Israel <iehindero419@stu.ui.edu.ng>
1 parent 8216d05 commit 38b5de8

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@mastra/core": patch
3+
---
4+
5+
Fixed LSP diagnostics always returning empty arrays on Windows when using `lsp: true` in Workspace.
6+
7+
Previously, `waitForDiagnostics` returned `[]` after the full timeout on Windows even when the language server published non-empty diagnostics. This affected any LSP server emitting VS Code-style URIs (e.g. lua-language-server). Now diagnostics are correctly returned regardless of how the language server encodes the file URI.
8+
9+
Fixes #17813
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { diagnosticsKey } from './client';
3+
4+
describe('diagnosticsKey', () => {
5+
it('returns the same key for Windows file URIs that differ in drive-letter case and colon encoding', () => {
6+
const fromPathToFileURL = 'file:///C:/Users/me/res/client.lua';
7+
const fromVscodeUri = 'file:///c%3A/Users/me/res/client.lua';
8+
9+
expect(diagnosticsKey(fromPathToFileURL)).toBe(diagnosticsKey(fromVscodeUri));
10+
});
11+
12+
it('lowercases the drive letter', () => {
13+
const key = diagnosticsKey('file:///C:/Users/me/res/client.lua');
14+
expect(key).toContain('c:');
15+
expect(key).not.toContain('C:');
16+
});
17+
18+
it('returns a normal posix path unchanged', () => {
19+
const key = diagnosticsKey('file:///home/me/res/client.lua');
20+
expect(key).toBe('/home/me/res/client.lua');
21+
});
22+
23+
it('passes through non-file-URI strings unchanged', () => {
24+
expect(diagnosticsKey('/already/a/path.ts')).toBe('/already/a/path.ts');
25+
});
26+
});

packages/core/src/workspace/lsp/client.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { createRequire } from 'node:module';
13-
import { pathToFileURL } from 'node:url';
13+
import { pathToFileURL, fileURLToPath } from 'node:url';
1414

1515
import type { ProcessHandle, SandboxProcessManager } from '../sandbox/process-manager';
1616
import type { LSPServerDef } from './types';
@@ -101,6 +101,31 @@ function toFileUri(fsPath: string): string {
101101
return pathToFileURL(fsPath).toString();
102102
}
103103

104+
/**
105+
* Normalize a file:// URI to a canonical fs-path-based key for diagnostics
106+
* map storage/lookup. On Windows, different LSP servers emit different
107+
* canonical forms for the same path (e.g. `file:///C:/...` vs
108+
* `file:///c%3A/...`), so we convert back to an OS path and compare those
109+
* instead of comparing URI strings directly.
110+
*/
111+
export function diagnosticsKey(uriOrPath: string): string {
112+
let fsPath: string;
113+
try {
114+
fsPath = uriOrPath.startsWith('file:') ? fileURLToPath(uriOrPath) : uriOrPath;
115+
} catch {
116+
return uriOrPath;
117+
}
118+
// Normalize Windows drive-letter paths so they compare equal regardless of
119+
// drive-letter casing or whether fileURLToPath produced a leading slash
120+
// (e.g. '/C:/Users/...' vs 'C:/Users/...' vs 'c:\\Users\\...'), independent
121+
// of the OS this code happens to run on.
122+
const driveMatch = fsPath.match(/^[\\/]?([a-zA-Z]):([\\/].*)$/);
123+
if (driveMatch) {
124+
return `${driveMatch[1]!.toLowerCase()}:${driveMatch[2]}`;
125+
}
126+
return fsPath;
127+
}
128+
104129
// =============================================================================
105130
// Timeout Helper
106131
// =============================================================================
@@ -175,7 +200,7 @@ export class LSPClient {
175200

176201
// Listen for published diagnostics
177202
this.connection.onNotification('textDocument/publishDiagnostics', (params: any) => {
178-
this.diagnostics.set(params.uri, params.diagnostics);
203+
this.diagnostics.set(diagnosticsKey(params.uri), params.diagnostics);
179204
});
180205

181206
this.connection.listen();
@@ -280,7 +305,7 @@ export class LSPClient {
280305
notifyOpen(filePath: string, content: string, languageId: string): void {
281306
if (!this.connection) return;
282307
const uri = toFileUri(filePath);
283-
this.diagnostics.delete(uri);
308+
this.diagnostics.delete(diagnosticsKey(uri));
284309
this.connection.sendNotification('textDocument/didOpen', {
285310
textDocument: { uri, languageId, version: 0, text: content },
286311
});
@@ -314,7 +339,7 @@ export class LSPClient {
314339
settleMs: number = 500,
315340
): Promise<any[]> {
316341
if (!this.connection) return [];
317-
const uri = toFileUri(filePath);
342+
const uri = diagnosticsKey(toFileUri(filePath));
318343
const startTime = Date.now();
319344
const initialDiagnostics = this.diagnostics.get(uri);
320345
let emptyReceivedAt: number | undefined;
@@ -350,7 +375,7 @@ export class LSPClient {
350375
notifyClose(filePath: string): void {
351376
if (!this.connection) return;
352377
const uri = toFileUri(filePath);
353-
this.diagnostics.delete(uri);
378+
this.diagnostics.delete(diagnosticsKey(uri));
354379
this.connection.sendNotification('textDocument/didClose', {
355380
textDocument: { uri },
356381
});

0 commit comments

Comments
 (0)