diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 7a25e70b..785a1d79 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -218,8 +218,17 @@ fn user_did_open(server: &Server, run: &Running, path: &Path, uri: &lsp::Uri, pa } } -fn unsupported(feature: &str) -> IdeError { - IdeError::unavailable(format!("no running language server supports {feature}")) +/// No target for `feature`: names the running servers that lack it, so the UI can +/// tell "the server can't do this" from "no server is running" (the UI's +/// `unavailableText` matches the "… doesn't support …" wording). +fn unsupported(servers: &[Arc], feature: &str) -> IdeError { + let running: Vec<&str> = servers.iter().filter(|s| s.running().is_some()).map(|s| s.name.as_str()).collect(); + if running.is_empty() { + IdeError::unavailable(format!("no running language server supports {feature}")) + } else { + let servers = running.join(", "); + IdeError::unavailable(format!("{servers} doesn't support {feature}")) + } } /// Folds per-server results: any success wins over errors; errors surface only @@ -830,7 +839,7 @@ impl LspManager { let snap = self.snapshot(doc)?; let targets = self.ready_targets(&snap.servers, pred).await; if targets.is_empty() { - return Err(unsupported(feature)); + return Err(unsupported(&snap.servers, feature)); } Ok((snap, targets)) } @@ -1183,7 +1192,7 @@ impl LspManager { let servers: Vec> = self.servers.lock().clone(); let targets = self.ready_targets(&servers, |c| c.has("workspaceSymbolProvider")).await; if targets.is_empty() { - return Err(unsupported("workspace symbols")); + return Err(unsupported(&servers, "workspace symbols")); } let requests = targets.iter().map(|t| { let cancel = &cancel; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 2a8b6b31..43160519 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -520,6 +520,20 @@ async fn inline_completion_only_from_servers_that_advertise_it() { assert!(plain.requested("textDocument/inlineCompletion").is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn unsupported_feature_names_the_running_servers_that_lack_it() { + let h = harness(&[("plain", incremental_caps()), ("other", json!({ "textDocumentSync": 2 }))]); + let (doc, _) = h.open("a.mk", "let x = fo"); + for name in ["plain", "other"] { + h.wait_status(name, ServerStatus::Ready).await; + } + let e = h.mgr.inline_completion(doc, Pos::new(0, 10), false, Default::default()).await.unwrap_err(); + assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable); + assert_eq!(e.message, "plain, other doesn't support inline completion"); + let e = h.mgr.type_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming).await.unwrap_err(); + assert_eq!(e.message, "plain, other doesn't support type hierarchy"); +} + #[tokio::test(flavor = "multi_thread")] async fn diagnostics_become_events_and_feed_code_actions() { let h = harness(&[("mock", incremental_caps())]); diff --git a/src/editor/lsp/inlineCompletion.test.ts b/src/editor/lsp/inlineCompletion.test.ts index ad418be0..1fd30751 100644 --- a/src/editor/lsp/inlineCompletion.test.ts +++ b/src/editor/lsp/inlineCompletion.test.ts @@ -21,6 +21,7 @@ const { ghostText, inlineCompletionExtension, nextWord, + registerInlineCompletionActions, requestInline, toSuggestions, } = await import("./inlineCompletion"); @@ -136,3 +137,49 @@ describe("inline completion", () => { expect(nextWord(" ")).toBe(" "); }); }); + +describe("inline completion without a provider", () => { + const unavailable = (message: string) => ({ + status: "error" as const, + error: { kind: "unavailable" as const, message }, + }); + const hintText = () => document.querySelector(".cm-lsp-hint")?.textContent ?? null; + + async function invoke(view: EditorView) { + const { useEditorRegistry } = await import("@/editor/api"); + const { getAction } = await import("@/platform/actions"); + useEditorRegistry.setState({ active: { view, ctx: view.state.facet(lspDoc)! } as never }); + const off = registerInlineCompletionActions(); + void getAction("editor.inlineCompletion.trigger")!.run({ ctx: {} } as never); + off(); + } + + it.each([ + [ + "names the running server that lacks it", + "jdtls doesn't support inline completion", + "jdtls doesn't support inline completion", + ], + [ + "says no server runs when none does", + "no running language server supports inline completion", + "No language server available", + ], + ])("on explicit invocation %s", async (_, message, shown) => { + const view = editor("String s = "); + ipc.lspInlineCompletion.mockResolvedValueOnce(unavailable(message)); + await invoke(view); + await vi.waitFor(() => expect(hintText()).toBe(shown)); + }); + + it("stays silent while typing", async () => { + const view = editor("String s = "); + ipc.lspInlineCompletion.mockResolvedValue(unavailable("jdtls doesn't support inline completion")); + expect(await requestInline(view, true)).toBe(false); + view.dispatch({ changes: { from: 11, insert: "x" }, userEvent: "input.type" }); + await new Promise((r) => setTimeout(r, 120)); + expect(hintText()).toBeNull(); + expect(ghostDom(view)).toBeNull(); + ipc.lspInlineCompletion.mockReset(); + }); +}); diff --git a/src/editor/lsp/inlineCompletion.ts b/src/editor/lsp/inlineCompletion.ts index 65dde708..0cbad830 100644 --- a/src/editor/lsp/inlineCompletion.ts +++ b/src/editor/lsp/inlineCompletion.ts @@ -21,6 +21,7 @@ import { type Command, type InlineCompletionItem, type RequestId, + unavailableText, } from "@/platform/ipc"; import { activeEditor } from "@/editor/api"; import { docOf, isUnavailable, lspRequest } from "./lsp"; @@ -312,7 +313,7 @@ export function registerInlineCompletionActions(): () => void { try { if (!(await requestInline(v, false))) showHint(v, "No inline suggestions"); } catch (e) { - showHint(v, isUnavailable(e) ? "No language server provides inline completion" : String(e)); + showHint(v, isUnavailable(e) ? unavailableText(e) : String(e)); } }), }, diff --git a/src/editor/lsp/lsp.test.ts b/src/editor/lsp/lsp.test.ts index 057735b8..6590de5a 100644 --- a/src/editor/lsp/lsp.test.ts +++ b/src/editor/lsp/lsp.test.ts @@ -8,7 +8,8 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true }; }); -const { lspRequest } = await import("./lsp"); +const { lspRequest, reportLspError } = await import("./lsp"); +const { IpcError } = await import("@/platform/ipc"); describe("lspRequest", () => { it("waits until open editors' edits reached the backend", async () => { @@ -27,3 +28,27 @@ describe("lspRequest", () => { view.destroy(); }); }); + +describe("reportLspError", () => { + const unavailable = (message: string) => new IpcError({ kind: "unavailable", message }); + const hintText = () => document.querySelector(".cm-lsp-hint")?.textContent ?? null; + + it.each([ + ["jdtls doesn't support type hierarchy", "jdtls doesn't support type hierarchy"], + ["pyright, ruff doesn't support code lens", "pyright, ruff doesn't support code lens"], + ["no running language server supports rename", "No language server available"], + ["jdtls is not running", "No language server available"], + ])("explicit %s → %s", (message, shown) => { + const view = new EditorView({ state: EditorState.create({ doc: "x" }), parent: document.body }); + reportLspError(view, unavailable(message), true); + expect(hintText()).toBe(shown); + view.destroy(); + }); + + it("stays silent for background requests", () => { + const view = new EditorView({ state: EditorState.create({ doc: "x" }), parent: document.body }); + reportLspError(view, unavailable("jdtls doesn't support inlay hints"), false); + expect(hintText()).toBeNull(); + view.destroy(); + }); +}); diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index 76ac58a0..64c9b446 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -2,7 +2,7 @@ import { Facet } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { whenAllSynced, type EditorDocContext } from "@/editor/api"; -import { IpcError, call, isCancelled, isTauri } from "@/platform/ipc"; +import { IpcError, call, isCancelled, isTauri, unavailableText } from "@/platform/ipc"; import type { IdeError } from "@/bindings"; import { showHint } from "./hint"; @@ -44,7 +44,8 @@ export const NO_SERVER = "No language server available"; /** * Error policy: background requests fail silently; explicit actions show a - * small hint at the caret ("No language server available" for missing servers). + * small hint at the caret ("No language server available" for missing servers, + * "jdtls doesn't support X" when the running server lacks the feature). */ export function reportLspError(view: EditorView | null, e: unknown, explicit: boolean) { if (isCancelled(e)) return; @@ -52,7 +53,7 @@ export function reportLspError(view: EditorView | null, e: unknown, explicit: bo if (!isUnavailable(e)) console.debug("[lsp]", e); return; } - showHint(view, isUnavailable(e) ? NO_SERVER : e instanceof Error ? e.message : String(e)); + showHint(view, isUnavailable(e) ? unavailableText(e, NO_SERVER) : e instanceof Error ? e.message : String(e)); } /** Runs `fn` once `ms` after the last call; `cancel()` drops a pending run. */ diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index ce651d65..3538b0dc 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -13,7 +13,7 @@ import type { EditorView } from "@codemirror/view"; import type { Text } from "@codemirror/state"; import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings"; -import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc"; +import { call, commands, isCancelled, isTauri, nextRequestId, unavailableText } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { basename } from "@/platform/project"; import { editorsFor, whenSynced, type EditorDocContext } from "@/editor/api"; @@ -482,7 +482,12 @@ export async function safeDelete(view: EditorView): Promise { applyDeletion(view, docBefore, target, comments); } catch (e) { if (isCancelled(e)) return; - showHint(view, isUnavailable(e) ? cannotPerform("No language server is available") : cannotPerform(errorText(e))); + showHint( + view, + isUnavailable(e) + ? cannotPerform(unavailableText(e, "No language server is available")) + : cannotPerform(errorText(e)), + ); } finally { running = null; } diff --git a/src/features/code-insight/ipc.ts b/src/features/code-insight/ipc.ts index 77ea565c..96718452 100644 --- a/src/features/code-insight/ipc.ts +++ b/src/features/code-insight/ipc.ts @@ -3,7 +3,7 @@ import type { EditorView } from "@codemirror/view"; import { activeEditor, editorsFor } from "@/editor/api"; import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; import { executeAction } from "@/platform/actions"; -import { IpcError, call, commands, isCancelled, isTauri } from "@/platform/ipc"; +import { IpcError, call, commands, isCancelled, isTauri, unavailableText } from "@/platform/ipc"; import type { IdeError } from "@/bindings"; import { maybeEditorService, notifications } from "@/workbench/contributions"; @@ -36,7 +36,7 @@ export function hint(text: string, tone: "info" | "warning" | "error" = "info") export function reportError(e: unknown) { if (isCancelled(e)) return; hint( - isUnavailable(e) ? "No language server available" : e instanceof Error ? e.message : String(e), + isUnavailable(e) ? unavailableText(e) : e instanceof Error ? e.message : String(e), isUnavailable(e) ? "info" : "error", ); } diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts index ba731f66..9936911d 100644 --- a/src/platform/ipc.ts +++ b/src/platform/ipc.ts @@ -34,6 +34,18 @@ export function isCancelled(e: unknown): boolean { return e instanceof IpcError && e.kind === "cancelled"; } +/** Backend `unavailable` message when servers run but none has the feature: "jdtls doesn't support inline completion". */ +const LACKS_FEATURE = /^\S.* doesn't support \S.*$/; + +/** + * What to tell the user when a language-server feature is `unavailable`: the + * running server that lacks it, so a working server doesn't read as missing; + * `fallback` when no server is running. + */ +export function unavailableText(e: unknown, fallback = "No language server available"): string { + return e instanceof IpcError && e.kind === "unavailable" && LACKS_FEATURE.test(e.message) ? e.message : fallback; +} + // Ids are shared by every window of the backend and outlive a reload there (a cancel that // arrives before its request began is remembered), so each page starts at a random point. let nextId = 1 + Math.floor(Math.random() * 0x7000_0000); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ab9debc0..2f3dfe2b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2575,7 +2575,27 @@ "Call Inline Completion": "调用内联补全", "Inline completion is off for this language": "此语言已关闭内联补全", "No inline suggestions": "没有内联建议", - "No language server provides inline completion": "没有语言服务器提供内联补全", + "{servers} doesn't support {feature}": "{servers} 不支持{feature}", + "completion": "代码补全", + "inline completion": "内联补全", + "hover": "快速文档", + "signature help": "形参信息", + "go to definition": "转到定义", + "go to declaration": "转到声明", + "go to type definition": "转到类型声明", + "go to implementation": "转到实现", + "find references": "查找用法", + "document highlights": "高亮用法", + "code actions": "代码操作", + "rename": "重命名", + "formatting": "重新格式化代码", + "range formatting": "格式化选中代码", + "inlay hints": "嵌入提示", + "code lens": "代码透镜", + "document links": "文档链接", + "call hierarchy": "调用层次结构", + "type hierarchy": "类型层次结构", + "workspace symbols": "工作区符号", "Insert Inline Proposal": "插入内联建议", "Insert Inline Proposal: Next Word": "插入内联建议:下一个单词", "Hide Inline Proposal": "隐藏内联建议",