diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 34d5f1e7..fd9e5f50 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -7,7 +7,12 @@ */ import type { Extension } from "@codemirror/state"; import { registerEditorContribution, type EditorDocContext } from "@/editor/api"; -import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; +import { + registerEditorBanner, + registerOverlay, + registerStatusItem, + registerToolWindow, +} from "@/workbench/contributions"; import { lspDoc } from "./lsp"; import { hintExtension } from "./hint"; import { completionExtension } from "./completion"; @@ -31,6 +36,7 @@ import { spellingExtension } from "./spelling"; import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups"; import { DOCUMENTATION_WINDOW } from "./docs"; import { prefetchMarkdown } from "./markdown"; +import { outsideProjectBanner } from "./outsideProject"; import { implementationGutter, registerNavigationActions } from "@/editor/navigation"; export function lspExtensions(ctx: EditorDocContext): Extension[] { @@ -66,6 +72,7 @@ export function activate(): () => void { // After the templates (order 90): live template / postfix expansion keeps ⇥ first. registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }), registerOverlay({ id: "editor-lsp.popups", component: LspPopups }), + registerEditorBanner(outsideProjectBanner), registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }), // IDEA's Documentation tool window: right side, listed once F1 moved documentation there. registerToolWindow({ diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 848652c0..23d7b657 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -47,6 +47,7 @@ import { type DeclarationTarget, } from "./syntaxFallback"; import { showHint, showPendingHint } from "./hint"; +import { outsideProjectReason } from "./outsideProject"; import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; import { readManyFileLines, targetLabel } from "./files"; import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links"; @@ -198,7 +199,16 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) ((await gotoWebReference(view, doc, pos, offset)) || (await followReferenceAt(view, doc, offset))) ) return; - showHint(view, serverDown ? `${NOT_FOUND[kind]}: no language server is running for this file` : NOT_FOUND[kind]); + // The server only checks this file's syntax: say why instead of just "not found". + const reason = serverDown ? null : outsideProjectReason(doc.path); + showHint( + view, + serverDown + ? `${NOT_FOUND[kind]}: no language server is running for this file` + : reason + ? `${tx(NOT_FOUND[kind])}. ${tx(reason)}` + : NOT_FOUND[kind], + ); return; } // IDEA: ⌘B on a declaration shows its usages. diff --git a/src/editor/lsp/outsideProject.test.ts b/src/editor/lsp/outsideProject.test.ts new file mode 100644 index 00000000..df0514b2 --- /dev/null +++ b/src/editor/lsp/outsideProject.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from "vitest"; +import { outsideProjectNotice } from "./outsideProject"; + +const text = (message: string) => outsideProjectNotice(message)?.text ?? null; + +it("recognizes each server's report that a file is outside its projects", () => { + const cases: [string, RegExp | null][] = [ + // jdtls: a file outside the module's source folders, and one outside every project. + [ + "Extra.java is not on the classpath of project service, only syntax errors are reported", + /source folders of its Java module/, + ], + ["Scratch.java is a non-project file, only syntax errors are reported", /imported Java project/], + // gopls: build constraints win over the generic "no package". + ["No packages found for open file x.go.\nThis file may be excluded due to its build tags", /build constraints/], + ["This file may be excluded due to its GOOS/GOARCH, or other build constraints.", /build constraints/], + ["No packages found for open file x.go.", /no package of the workspace/], + // rust-analyzer `unlinked-file`. + [ + "This file is not included in any crates, so rust-analyzer can't offer IDE services.", + /not included in any crate/, + ], + // Ordinary errors are not such reports. + ["undefined: missingSymbol", null], + ["invalid build tags syntax", null], + ["ActionDTO cannot be resolved to a type", null], + ["Missing artifact demo:api:jar:1.0", null], + ]; + for (const [message, expected] of cases) { + const got = text(message); + if (expected) expect(got, message).toMatch(expected); + else expect(got, message).toBeNull(); + } + // A build reload is offered only where it can bring the file into a project. + expect(outsideProjectNotice("A.java is a non-project file, only syntax errors are reported")?.reload).toBe(true); + expect(outsideProjectNotice("No packages found for open file x.go.")?.reload).toBeFalsy(); +}); + +it("shows a banner while the server reports the file, with the build reloads available", async () => { + const { renderHook, act } = await import("@testing-library/react"); + const { setFileDiagnostics } = await import("./diagnostics"); + const { registerActions } = await import("@/platform/actions"); + const { outsideProjectBanner } = await import("./outsideProject"); + const off = registerActions([{ id: "maven.reload", title: "Reload All Maven Projects", run: () => {} }]); + const path = "/p/service/src/extra/Extra.java"; + try { + const { result } = renderHook(() => outsideProjectBanner.useBanner(path)); + expect(result.current).toBeNull(); + const warning = { + range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }, + severity: "warning" as const, + message: "Extra.java is not on the classpath of project service, only syntax errors are reported", + source: "Java", + code: "32", + }; + act(() => setFileDiagnostics(path, "jdtls", [warning])); + expect(result.current?.level).toBe("warning"); + expect(result.current?.text).toMatch(/source folders of its Java module/); + expect(result.current?.actions?.map((a) => a.label)).toEqual(["Reload Maven Projects"]); + act(() => setFileDiagnostics(path, "jdtls", [])); + expect(result.current).toBeNull(); + } finally { + off(); + setFileDiagnostics(path, "jdtls", []); + } +}); diff --git a/src/editor/lsp/outsideProject.ts b/src/editor/lsp/outsideProject.ts new file mode 100644 index 00000000..7ce7ec88 --- /dev/null +++ b/src/editor/lsp/outsideProject.ts @@ -0,0 +1,85 @@ +/** + * Files a language server only checks for syntax because they belong to none of + * its projects: a Java file outside every module's source folders, a Rust file in + * no crate, a Go file in no package of the workspace. The server says so in a + * diagnostic; navigation, completion and usages then fail without saying why. + */ +import { useSyncExternalStore } from "react"; +import { executeAction, getAction, isActionEnabled } from "@/platform/actions"; +import type { EditorBannerContribution } from "@/workbench/contributions"; +import { diagnosticsFor, subscribeDiagnostics } from "./diagnostics"; + +/** `reload`: a build reload may bring the file into a project (a module the import missed). */ +const NOTICES: readonly { test: RegExp; text: string; reload?: boolean }[] = [ + // jdtls: "Foo.java is not on the classpath of project app, only syntax errors are reported" + { + test: /is not on the classpath of project .*only syntax errors are reported/, + text: "This file is outside the source folders of its Java module: only syntax errors are reported, and navigation and completion don't work.", + reload: true, + }, + // jdtls: "Foo.java is a non-project file, only syntax errors are reported" + { + test: /is a non-project file, only syntax errors are reported/, + text: "This file does not belong to an imported Java project: only syntax errors are reported, and navigation and completion don't work.", + reload: true, + }, + // gopls, before its generic "No packages found" below. + { + test: /excluded[^\n]*(?:build|GOOS\/GOARCH)/i, + text: "This Go file is excluded by the current build constraints.", + }, + { + test: /^No packages found for open file/, + text: "This Go file belongs to no package of the workspace: code insight is limited.", + }, + // rust-analyzer's `unlinked-file`. + { + test: /not included in any crates/, + text: "This Rust file is not included in any crate: rust-analyzer can't offer code insight for it.", + }, +]; + +type Notice = (typeof NOTICES)[number]; + +/** What a server's `message` means for its file, when it says the file is outside its projects. */ +export function outsideProjectNotice(message: string): Notice | null { + return NOTICES.find((n) => n.test.test(message)) ?? null; +} + +function noticeFor(path: string): Notice | null { + for (const d of diagnosticsFor(path)) { + const notice = outsideProjectNotice(d.message); + if (notice) return notice; + } + return null; +} + +/** Why code insight is limited in `path`, from its current diagnostics, or null. */ +export function outsideProjectReason(path: string): string | null { + return noticeFor(path)?.text ?? null; +} + +const RELOADS = [ + { id: "maven.reload", label: "Reload Maven Projects" }, + { id: "gradle.reload", label: "Reload Gradle Project" }, +]; + +/** The notice above an editor whose file a language server treats as outside its projects. */ +export const outsideProjectBanner: EditorBannerContribution = { + id: "lsp.outsideProject", + order: 5, + useBanner: (path) => { + const notice = useSyncExternalStore( + (l) => subscribeDiagnostics(path, l), + () => noticeFor(path), + ); + if (!notice) return null; + const actions = notice.reload + ? RELOADS.filter(({ id }) => { + const action = getAction(id); + return action && isActionEnabled(action); + }).map(({ id, label }) => ({ label, run: () => void executeAction(id) })) + : []; + return { text: notice.text, level: "warning", actions }; + }, +}; diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 5c31bd84..4aa131f4 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -303,6 +303,29 @@ describe("navigation wiring", () => { expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Cannot find declaration to go to"); }); + it("says why when the server only checks the file's syntax", async () => { + const { setFileDiagnostics } = await import("./diagnostics"); + setFileDiagnostics("/p/a.ts", "jdtls", [ + { + range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }, + severity: "warning", + message: "a.ts is not on the classpath of project app, only syntax errors are reported", + source: "Java", + code: "32", + }, + ]); + try { + cmd.lspGoto.mockReturnValueOnce(ok([])); + const view = editor("const x = 1;\nx;", "typescript", 13); + await gotoTargets(view, "definition"); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe( + "Cannot find declaration to go to. This file is outside the source folders of its Java module: only syntax errors are reported, and navigation and completion don't work.", + ); + } finally { + setFileDiagnostics("/p/a.ts", "jdtls", []); + } + }); + it("falls back to the local definition when the server is not ready", async () => { cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 0, 1)] }))); const view = editor("const x = 1;\nx;", "typescript", 13); diff --git a/src/features/code-insight/goConstraints.test.ts b/src/features/code-insight/goConstraints.test.ts deleted file mode 100644 index 33f80459..00000000 --- a/src/features/code-insight/goConstraints.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, it } from "vitest"; -import { goConstraintExcluded } from "./goConstraints"; - -it("recognizes build exclusion without confusing unrelated package errors", () => { - expect( - goConstraintExcluded("No packages found for open file x.go.\nThis file may be excluded due to its build tags"), - ).toBe(true); - expect(goConstraintExcluded("This file may be excluded due to its GOOS/GOARCH, or other build constraints.")).toBe( - true, - ); - expect(goConstraintExcluded("No packages found for open file x.go.")).toBe(false); - expect(goConstraintExcluded("undefined: missingSymbol")).toBe(false); - expect(goConstraintExcluded("invalid build tags syntax")).toBe(false); -}); diff --git a/src/features/code-insight/goConstraints.ts b/src/features/code-insight/goConstraints.ts deleted file mode 100644 index dacc80d8..00000000 --- a/src/features/code-insight/goConstraints.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Only gopls diagnostics that explicitly identify build exclusion qualify. */ -export function goConstraintExcluded(message: string): boolean { - return /excluded[^\n]*(?:build|GOOS\/GOARCH)/i.test(message); -} diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index 410c12ee..ca142b4e 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -5,12 +5,7 @@ */ import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; -import { - registerEditorBanner, - registerOverlay, - registerStatusItem, - registerToolWindow, -} from "@/workbench/contributions"; +import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore"; import { installProblemDecorations } from "./decorations"; import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView"; @@ -20,7 +15,6 @@ import { findUsages, rerunActiveUsages, resetUsages, showUsageResults, showUsage import { HierarchyTabs, HierarchyView } from "./HierarchyView"; import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; -import { goConstraintExcluded } from "./goConstraints"; import { ServerStatus } from "./ServerStatus"; import { installWorkspaceEditUndo } from "./workspaceUndo"; @@ -48,16 +42,6 @@ export function activate(): () => void { }); const disposers = [ unsubProject, - registerEditorBanner({ - id: "go.buildConstraints", - useBanner: (path) => { - const excluded = useProblems( - (s) => - path.endsWith(".go") && (s.map.get(path)?.get("gopls") ?? []).some((d) => goConstraintExcluded(d.message)), - ); - return excluded ? { text: "This Go file is excluded by the current build constraints." } : null; - }, - }), installProblemsEvents(), installProblemDecorations(), installServerEvents(), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 14586338..62eaf076 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5554,5 +5554,11 @@ "External file changes sync may be slow": "外部文件更改同步可能较慢", "recursive": "递归", "Java project import failed": "Java 项目导入失败", - "The Java project cannot be imported because these files use a name Windows reserves for devices: {paths}. Delete or rename them, then restart the Java language server.": "无法导入 Java 项目,因为这些文件使用了 Windows 保留的设备名:{paths}。请删除或重命名这些文件,然后重启 Java 语言服务器。" + "The Java project cannot be imported because these files use a name Windows reserves for devices: {paths}. Delete or rename them, then restart the Java language server.": "无法导入 Java 项目,因为这些文件使用了 Windows 保留的设备名:{paths}。请删除或重命名这些文件,然后重启 Java 语言服务器。", + "This file is outside the source folders of its Java module: only syntax errors are reported, and navigation and completion don't work.": "此文件不在所属 Java 模块的源码目录中:只报告语法错误,跳转和补全不可用。", + "This file does not belong to an imported Java project: only syntax errors are reported, and navigation and completion don't work.": "此文件不属于已导入的 Java 项目:只报告语法错误,跳转和补全不可用。", + "This Go file belongs to no package of the workspace: code insight is limited.": "此 Go 文件不属于工作区中的任何包:代码洞察功能受限。", + "This Rust file is not included in any crate: rust-analyzer can't offer code insight for it.": "此 Rust 文件未包含在任何 crate 中:rust-analyzer 无法为它提供代码洞察。", + "Reload Maven Projects": "重新加载 Maven 项目", + "Reload Gradle Project": "重新加载 Gradle 项目" }