fix(lsp): normalized published diagnostic uri keys
Canonicalized file URI keys at the diagnostics map boundary so server and client spellings match across percent encoding and Windows casing. Added regression coverage for equivalent percent-encoded URIs and the marksman Windows drive-letter form. Fixes #7662
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
|
||||
- Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed LSP diagnostics being dropped when servers normalize file URI percent-encoding or Windows path casing.
|
||||
|
||||
## [17.2.8] - 2026-08-04
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ServerConfig,
|
||||
WorkspaceEdit,
|
||||
} from "./types";
|
||||
import { detectLanguageId, fileToUri } from "./utils";
|
||||
import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils";
|
||||
|
||||
// =============================================================================
|
||||
// Client State
|
||||
@@ -787,7 +787,7 @@ export async function getOrCreateClient(
|
||||
proc,
|
||||
config,
|
||||
requestId: 0,
|
||||
diagnostics: new Map(),
|
||||
diagnostics: new EquivalentUriMap(),
|
||||
diagnosticsVersion: 0,
|
||||
dynamicCapabilityRegistrations: new Map(),
|
||||
openFiles: new Map(),
|
||||
|
||||
@@ -78,6 +78,35 @@ function laxUriToFile(uri: string): string {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Map that treats equivalent file URI spellings as the same key. */
|
||||
export class EquivalentUriMap<Value> extends Map<string, Value> {
|
||||
#key(uri: string): string {
|
||||
if (!uri.startsWith("file://")) return uri;
|
||||
const filePath = path.normalize(uriToFile(uri));
|
||||
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
|
||||
}
|
||||
|
||||
override delete(uri: string): boolean {
|
||||
const key = this.#key(uri);
|
||||
return super.delete(key);
|
||||
}
|
||||
|
||||
override get(uri: string): Value | undefined {
|
||||
const key = this.#key(uri);
|
||||
return super.get(key);
|
||||
}
|
||||
|
||||
override has(uri: string): boolean {
|
||||
const key = this.#key(uri);
|
||||
return super.has(key);
|
||||
}
|
||||
|
||||
override set(uri: string, value: Value): this {
|
||||
const key = this.#key(uri);
|
||||
return super.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Diagnostic Formatting
|
||||
// =============================================================================
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, FileFormatResult } f
|
||||
import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client";
|
||||
import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config";
|
||||
import type { Diagnostic, LinterClient, LspClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types";
|
||||
import { fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils";
|
||||
import { EquivalentUriMap, fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils";
|
||||
import type { DeferredDiagnosticsEntry, ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
|
||||
import { type ptree, TempDir } from "@oh-my-pi/pi-utils";
|
||||
@@ -47,7 +47,7 @@ function createClient(cwd: string, config: ServerConfig): LspClient {
|
||||
config,
|
||||
proc: {} as ptree.ChildProcess<"pipe">,
|
||||
requestId: 0,
|
||||
diagnostics: new Map(),
|
||||
diagnostics: new EquivalentUriMap(),
|
||||
diagnosticsVersion: 0,
|
||||
openFiles: new Map(),
|
||||
pendingRequests: new Map(),
|
||||
@@ -438,6 +438,51 @@ describe("LSP diagnostics freshness", () => {
|
||||
expect(result?.messages.some(m => m.includes("stale error"))).toBe(false);
|
||||
});
|
||||
|
||||
it("matches published diagnostics when the server renormalizes the document URI", async () => {
|
||||
const filePath = path.join(tempDir.path(), "renormalized.ts");
|
||||
const uri = fileToUri(filePath);
|
||||
const serverUri = uri.replace("/renormalized.ts", "/%72enormalized.ts");
|
||||
const client = createClient(tempDir.path(), TEST_SERVER);
|
||||
const clock = new VirtualClock(Date.now());
|
||||
installVirtualTime(clock);
|
||||
|
||||
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined });
|
||||
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]);
|
||||
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
|
||||
vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => {
|
||||
const syncedUri = fileToUri(syncedFilePath);
|
||||
mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" });
|
||||
});
|
||||
vi.spyOn(lspClient, "notifySaved").mockImplementation(async mockClient => {
|
||||
clock.in(10, () => {
|
||||
publishDiagnostics(mockClient, serverUri, [createDiagnostic("renormalized URI error")], 1);
|
||||
});
|
||||
});
|
||||
|
||||
const writethrough = createLspWritethrough(tempDir.path(), {
|
||||
enableFormat: false,
|
||||
enableDiagnostics: true,
|
||||
});
|
||||
const result = await writethrough(filePath, "export const value = missing;\n");
|
||||
|
||||
expect(result?.errored).toBe(true);
|
||||
expect(result?.messages.some(message => message.includes("renormalized URI error"))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Windows drive-letter case and percent-encoding differences", () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (!platformDescriptor) throw new Error("process.platform descriptor is unavailable");
|
||||
Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" });
|
||||
try {
|
||||
const diagnostics = new EquivalentUriMap<string>();
|
||||
diagnostics.set("file:///c%3A/Users/serge/doc.md", "published");
|
||||
|
||||
expect(diagnostics.get("file:///C:/Users/serge/doc.md")).toBe("published");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns completed pull diagnostics inside the inline write window", async () => {
|
||||
const filePath = path.join(tempDir.path(), "pull-only.ts");
|
||||
const uri = fileToUri(filePath);
|
||||
|
||||
Reference in New Issue
Block a user