From b747d4e3ec3a1ba0da50d51abc6f570a5f80c11e Mon Sep 17 00:00:00 2001 From: Myles Scolnick Date: Wed, 29 Apr 2026 11:36:51 -0700 Subject: [PATCH] fix: include diagnostic error codes in tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #49 In `processDiagnostics()` (`src/plugin.ts`), format the CodeMirror diagnostic's `source` field as `"source(code)"` when the LSP diagnostic carries a `code`. Falls back to the bare source (or `languageId`) when no code is provided. Examples: - `source: "basedpyright"`, `code: "reportUnusedImport"` → `"basedpyright(reportUnusedImport)"` - `source: "tsserver"`, `code: 2304` → `"tsserver(2304)"` - `source: "basedpyright"`, no code → `"basedpyright"` - no source, `code: "E001"` → `"typescript(E001)"` (uses `languageId`) --- src/__tests__/language-server-plugin.test.ts | 119 +++++++++++++++++++ src/plugin.ts | 60 ++++++++-- 2 files changed, 167 insertions(+), 12 deletions(-) diff --git a/src/__tests__/language-server-plugin.test.ts b/src/__tests__/language-server-plugin.test.ts index eac661f..3e692fa 100644 --- a/src/__tests__/language-server-plugin.test.ts +++ b/src/__tests__/language-server-plugin.test.ts @@ -637,4 +637,123 @@ describe("LanguageServerPlugin", () => { ).not.toThrow(); }); }); + + describe("processDiagnostics", () => { + let plugin: LanguageServerPlugin; + + beforeEach(() => { + plugin = new LanguageServerPlugin({ + client: mockClient, + documentUri: "file:///test.ts", + languageId: "typescript", + view: mockView, + featureOptions, + }); + + // Avoid real code-action requests during diagnostic processing + vi.spyOn(plugin as any, "requestCodeActions").mockResolvedValue( + undefined, + ); + + vi.clearAllMocks(); + }); + + it("includes the diagnostic code in the source when present", async () => { + const addDiagnosticsSpy = vi + .spyOn(plugin as any, "addDiagnostics") + .mockImplementation(() => { }); + + await plugin.processDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: 'Import "os" is not accessed', + source: "basedpyright", + code: "reportUnusedImport", + }, + ], + }); + + expect(addDiagnosticsSpy).toHaveBeenCalledTimes(1); + const diagnostics = addDiagnosticsSpy.mock.calls[0][0]; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].source).toBe( + "basedpyright(reportUnusedImport)", + ); + }); + + it("leaves the source unchanged when no code is present", async () => { + const addDiagnosticsSpy = vi + .spyOn(plugin as any, "addDiagnostics") + .mockImplementation(() => { }); + + await plugin.processDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "Some warning", + source: "basedpyright", + }, + ], + }); + + const diagnostics = addDiagnosticsSpy.mock.calls[0][0]; + expect(diagnostics[0].source).toBe("basedpyright"); + }); + + it("falls back to languageId when no source is provided", async () => { + const addDiagnosticsSpy = vi + .spyOn(plugin as any, "addDiagnostics") + .mockImplementation(() => { }); + + await plugin.processDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "Some error", + code: "E001", + }, + ], + }); + + const diagnostics = addDiagnosticsSpy.mock.calls[0][0]; + expect(diagnostics[0].source).toBe("typescript(E001)"); + }); + + it("coerces numeric codes to strings in the source", async () => { + const addDiagnosticsSpy = vi + .spyOn(plugin as any, "addDiagnostics") + .mockImplementation(() => { }); + + await plugin.processDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "Type error", + source: "tsserver", + code: 2304, + }, + ], + }); + + const diagnostics = addDiagnosticsSpy.mock.calls[0][0]; + expect(diagnostics[0].source).toBe("tsserver(2304)"); + }); + }); }); diff --git a/src/plugin.ts b/src/plugin.ts index 75da1d1..766b60b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -24,10 +24,10 @@ import type { CompletionResult, } from "@codemirror/autocomplete"; import { + Annotation, type Extension, StateEffect, StateField, - Annotation, } from "@codemirror/state"; import type { PluginValue, ViewUpdate } from "@codemirror/view"; import type * as LSP from "vscode-languageserver-protocol"; @@ -99,15 +99,45 @@ const SIGNATURE_TOOLTIP_MAX_LINES_BACK = 20; export class LanguageServerPlugin implements PluginValue { private documentVersion: number; private pluginId: string; + /** + * The language server client instance. + */ public client: LanguageServerClient; + /** + * URI of the current document being edited. If not provided, must be passed via the documentUri facet. + */ public documentUri: string; + /** + * Language identifier (e.g., 'typescript', 'javascript', etc.). If not provided, must be passed via the languageId facet. + */ public languageId: string; + /** + * The editor view instance. + */ public view: EditorView; + /** + * Whether to allow HTML content in hover tooltips and other UI elements. + */ public allowHTMLContent: boolean; + /** + * Whether to prefer snippet insertion for completions when available. + */ public useSnippetOnCompletion: boolean; + /** + * Whether to send incremental changes to the language server. + */ public sendIncrementalChanges: boolean; + /** + * Feature options for the language server plugin. + */ public featureOptions: Required; + /** + * Callback triggered when a go-to-definition action is performed. + */ public onGoToDefinition: ((result: DefinitionResult) => void) | undefined; + /** + * Callback to render markdown content. + */ public markdownRenderer: (markdown: string) => string; private disposeListener?: () => void; @@ -308,10 +338,10 @@ export class LanguageServerPlugin implements PluginValue { const token = match ? // Try prefix-based match, then fall back to general word match - (context.matchBefore(match) ?? - context.matchBefore(/[a-zA-Z0-9_]+/)) + (context.matchBefore(match) ?? + context.matchBefore(/[a-zA-Z0-9_]+/)) : // Fallback to matching any word character - context.matchBefore(/[a-zA-Z0-9_]+/); + context.matchBefore(/[a-zA-Z0-9_]+/); let { pos } = context; const sortedItems = sortCompletionItems( @@ -443,12 +473,12 @@ export class LanguageServerPlugin implements PluginValue { } const severityMap: Record = - { - [DiagnosticSeverity.Error]: "error", - [DiagnosticSeverity.Warning]: "warning", - [DiagnosticSeverity.Information]: "info", - [DiagnosticSeverity.Hint]: "info", - }; + { + [DiagnosticSeverity.Error]: "error", + [DiagnosticSeverity.Warning]: "warning", + [DiagnosticSeverity.Information]: "info", + [DiagnosticSeverity.Hint]: "info", + }; const diagnostics = params.diagnostics.map( async ({ range, message, severity, code, source }) => { @@ -460,7 +490,7 @@ export class LanguageServerPlugin implements PluginValue { (action): Action => ({ name: "command" in action && - typeof action.command === "object" + typeof action.command === "object" ? action.command?.title || action.title : action.title, apply: async () => { @@ -501,6 +531,12 @@ export class LanguageServerPlugin implements PluginValue { }), ); + const baseSource = source || this.languageId; + const formattedSource = + code != null && code !== "" + ? `${baseSource}(${code})` + : baseSource; + const diagnostic: Diagnostic = { from: posToOffsetOrZero(this.view.state.doc, range.start), to: posToOffsetOrZero(this.view.state.doc, range.end), @@ -511,7 +547,7 @@ export class LanguageServerPlugin implements PluginValue { dom.innerHTML = this.markdownRenderer(message); return dom; }, - source: source || this.languageId, + source: formattedSource, markClass: this.pluginId, actions: codemirrorActions, };