diff --git a/src/editor/lsp/completion.test.ts b/src/editor/lsp/completion.test.ts index 83f537c3..e75cf69b 100644 --- a/src/editor/lsp/completion.test.ts +++ b/src/editor/lsp/completion.test.ts @@ -56,6 +56,27 @@ function item(over: Partial = {}): CompletionItem { }; } +describe("PHP construct completion documentation", () => { + const req = { doc: 1, path: "/p/a.php", language: "PHP", pos: 3, text: EditorState.create({ doc: "emp" }).doc }; + + it("documents a keyword without changing its insertion", () => { + const c = makeCompletion(item({ label: "empty", kind: "keyword", insertText: "empty" }), req); + expect(c.lsp.documentation?.value).toContain("empty(mixed $var): bool"); + expect(c.lsp.insertText).toBe("empty"); + }); + + it("preserves server documentation and leaves methods and other languages alone", () => { + const documentation = { kind: "markdown" as const, value: "Server documentation" }; + expect(makeCompletion(item({ label: "empty", kind: "keyword", documentation }), req).lsp.documentation).toEqual( + documentation, + ); + expect(makeCompletion(item({ label: "empty", kind: "method" }), req).lsp.documentation).toBeNull(); + expect( + makeCompletion(item({ label: "empty", kind: "keyword" }), { ...req, language: "JavaScript" }).lsp.documentation, + ).toBeNull(); + }); +}); + describe("toCmCompletion", () => { it("maps label, detail, kind and sort text", () => { const c = toCmCompletion( diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 7a9bf7ca..4cbb7543 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -56,6 +56,7 @@ import { editorPrefs } from "@/platform/editorSettings"; import { tx } from "@/platform/i18n"; import { showHint } from "./hint"; import { inplaceRenameActive } from "./inplaceRename"; +import { phpConstructDocumentation } from "./phpConstructs"; interface RequestContext { doc: number; @@ -87,6 +88,8 @@ function resolveItem(doc: number, item: CompletionItem): Promise if (!p) { p = lspRequest(() => commands.lspResolveCompletion(doc, item)).then( (r) => { + if (isEmptyMarkup(r.documentation) && !isEmptyMarkup(item.documentation)) + r = { ...r, documentation: item.documentation }; resolvedItems.set(item, r); return r; }, @@ -365,6 +368,10 @@ async function fallback(ctx: CompletionContext): Promise commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), kind)); const seen = new Set(); diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 33052b67..ca7b84ad 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -51,7 +51,7 @@ import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; import { identifierAt, readManyFileLines } from "./files"; import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links"; import { featureBackoff } from "./availability"; -import { requestHover } from "./hover"; +import { requestHover, showPhpConstructDocumentation } from "./hover"; import { declarationSummary, renderCode } from "./markdown"; import { editorPrefs } from "@/platform/editorSettings"; import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs"; @@ -157,6 +157,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) if (kind === "definition" || kind === "declaration") { const own = contributedAt(view.state, CONTRIBUTED_DEFINITION, offset); if (own?.(view, offset)) return; + if (showPhpConstructDocumentation(view, offset)) return; } const doc = docOf(view); if (!doc) return; diff --git a/src/editor/lsp/phpConstructs.test.ts b/src/editor/lsp/phpConstructs.test.ts new file mode 100644 index 00000000..a51aa6b0 --- /dev/null +++ b/src/editor/lsp/phpConstructs.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { languages } from "@codemirror/language-data"; +import { phpConstructAt, phpConstructDocumentation } from "./phpConstructs"; +import { lspDoc } from "./lsp"; +import { quickDefinition, requestHover } from "./hover"; +import { gotoTargets } from "./navigation"; +import { closePopup, useLspPopup } from "./popupStore"; + +const php = await languages.find((l) => l.name === "PHP")!.load(); +const views: EditorView[] = []; +afterEach(() => { + closePopup(); + for (const view of views.splice(0)) view.destroy(); +}); + +function state(doc: string) { + return EditorState.create({ doc, extensions: [php] }); +} + +describe("PHP language constructs", () => { + it.each(["empty", "isset", "EMPTY"])("documents %s at either edge of its name", (name) => { + const s = state(`empty();", + "empty();", + "empty($x)

{ + const s = state(text); + for (const match of text.matchAll(/empty/g)) expect(phpConstructAt(s, match.index + 2, "php")).toBeNull(); + }); + + it("does not attach PHP docs to other languages or ordinary functions", () => { + expect(phpConstructAt(state(" { + const view = new EditorView({ + state: EditorState.create({ + doc: " gotoTargets(view, "definition"), + () => gotoTargets(view, "declaration"), + () => quickDefinition(view), + ]) { + await action(); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("doc"); + if (popup?.kind === "doc") expect(popup.entries[0]?.markup.value).toContain("no source declaration"); + closePopup(); + } + }); +}); diff --git a/src/editor/lsp/phpConstructs.ts b/src/editor/lsp/phpConstructs.ts new file mode 100644 index 00000000..47dd905f --- /dev/null +++ b/src/editor/lsp/phpConstructs.ts @@ -0,0 +1,40 @@ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; + +const CONSTRUCTS: Readonly> = { + empty: { + signature: "empty(mixed $var): bool", + description: + "Returns true when the value is falsey or the variable does not exist. Checking an undefined variable does not emit a warning.", + }, + isset: { + signature: "isset(mixed $var, mixed ...$vars): bool", + description: + "Returns true only when every supplied variable exists and is not null. Checks stop at the first unset or null variable.", + }, +}; + +export function phpConstructDocumentation(language: string | null, name: string): string | null { + if (language?.toLowerCase() !== "php") return null; + const key = name.toLowerCase(); + const entry = Object.hasOwn(CONSTRUCTS, key) ? CONSTRUCTS[key] : undefined; + if (!entry) return null; + return [ + "```php\n" + entry.signature + "\n```", + entry.description, + "PHP language construct; there is no source declaration to navigate to.", + `[PHP manual](https://www.php.net/manual/en/function.${key}.php)`, + ].join("\n\n"); +} + +/** Only an unqualified call, never a method, variable, string, comment or HTML text. */ +export function phpConstructAt(state: EditorState, pos: number, language: string | null) { + if (language?.toLowerCase() !== "php") return null; + for (const side of [-1, 1] as const) { + const node = syntaxTree(state).resolveInner(pos, side); + if (node.name !== "Name" || node.parent?.name !== "CallExpression" || node.parent.from !== node.from) continue; + const markdown = phpConstructDocumentation(language, state.sliceDoc(node.from, node.to)); + if (markdown) return { from: node.from, to: node.to, markdown }; + } + return null; +} diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 1726b6d2..a7fb9892 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -11,10 +11,10 @@ * symbols — is left to the server. Ambiguous cases ask first. */ import type { EditorView } from "@codemirror/view"; -import { commands, isCancelled, type Pos, type WorkspaceEdit } from "@/platform/ipc"; +import { commands, isCancelled, unavailableText, type Pos, type WorkspaceEdit } from "@/platform/ipc"; import { whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; -import { docOf, lspRequest, reportLspError } from "./lsp"; +import { docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { comparePos, offsetToPos, rangeToOffsets } from "./positions"; import { showHint } from "./hint"; import { caretAnchor, openPopup, type PreviewOccurrence } from "./popupStore"; @@ -69,7 +69,7 @@ export async function startRename(view: EditorView) { range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos)); } catch (e) { if (isCancelled(e)) return; - if (!(await startSyntaxRename(view, doc, pos))) reportLspError(view, e, true); + if (!(await startSyntaxRename(view, doc, pos, e))) reportLspError(view, e, true); return; } if (!range) { @@ -258,7 +258,7 @@ async function syntaxPlan(view: EditorView, doc: EditorDocContext, pos: Pos): Pr } /** Opens the rename popup for a syntax-based local rename. False when the fallback can't help. */ -async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: Pos): Promise { +async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: Pos, cause?: unknown): Promise { let result; try { await whenSynced(view); @@ -269,7 +269,8 @@ async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: P } if (result.kind === "unsupported") return false; if (result.kind === "refused") { - showHint(view, result.message); + const reason = isUnavailable(cause) ? unavailableText(cause) : cause instanceof Error ? cause.message : null; + showHint(view, reason ? `${reason}. ${result.message}` : result.message); return true; } const { plan } = result; diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index e709a8e3..00027899 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -350,6 +350,24 @@ describe("navigation wiring", () => { }); describe("rename wiring", () => { + it("preserves the server's missing capability when syntax cannot rename a method", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "intelephense doesn't support rename" }, + }), + ); + cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "run", range: r(0, 4, 7), reason: "member" })); + const view = editor("obj.run();", "php", 5); + await startRename(view); + const message = document.querySelector(".cm-lsp-hint")?.textContent; + expect(message).toContain("intelephense doesn't support rename"); + expect(message).toContain("only local variables"); + expect(message).not.toContain("without the language server"); + expect(view.state.doc.toString()).toBe("obj.run();"); + expect(useLspPopup.getState().popup).toBeNull(); + }); + it("renames a certain local in place when the server is not ready", async () => { cmd.syntaxLocals.mockReturnValueOnce(ok(local({ references: [r(1, 4, 5)] }))); cmd.syntaxNodeAt diff --git a/src/editor/lsp/syntaxFallback.ts b/src/editor/lsp/syntaxFallback.ts index 496c0e1e..f9ad9334 100644 --- a/src/editor/lsp/syntaxFallback.ts +++ b/src/editor/lsp/syntaxFallback.ts @@ -243,7 +243,7 @@ export async function planLocalRename( if (locals.status === "notLocal") return { kind: "refused", - message: tx`Can't rename '${locals.name}' without the language server: only local variables can be renamed based on syntax`, + message: tx`Can't rename '${locals.name}' based on syntax: only local variables can be renamed this way`, }; const { symbol } = locals; const ranges = [symbol.definition, ...symbol.references].sort((a, b) => comparePos(a.start, b.start)); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b8242c55..7115b777 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3806,7 +3806,6 @@ "Save '{word}' to project-level dictionary": "将 '{word}' 保存到项目级词典", "Save '{word}' to application-level dictionary": "将 '{word}' 保存到应用程序级词典", "Typo: In word '{word}'": "拼写错误:单词 '{word}'", - "Can't rename '{name}' without the language server: only local variables can be renamed based on syntax": "没有语言服务器时无法重命名 '{name}':基于语法只能重命名局部变量", "Choose Super Class or Interface ({count} found)": "选择超类或接口(找到 {count} 个)", "Choose Super Method of {name} ({count} found)": "选择 {name} 的超方法(找到 {count} 个)", "Choose Implementation of {name} ({count} found)": "选择 {name} 的实现(找到 {count} 个)", @@ -4959,5 +4958,8 @@ "Tomcat server.xml has no HTTP connector": "Tomcat 的 server.xml 中没有 HTTP 连接器", "WAR contains an invalid path": "WAR 中包含无效路径", "Context path must start with / and contain only letters, digits, /, - or _": "上下文路径必须以 / 开头,且只能包含字母、数字、/、- 或 _", - "Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar": "Tomcat 安装目录必须包含 bin/catalina.sh 或 bin/catalina.bat,以及 lib/catalina.jar" + "Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar": "Tomcat 安装目录必须包含 bin/catalina.sh 或 bin/catalina.bat,以及 lib/catalina.jar", + "Can't rename '{name}' based on syntax: only local variables can be renamed this way": "无法基于语法重命名 '{name}':这种方式只能重命名局部变量", + "Returns true when the value is falsey or the variable does not exist. Checking an undefined variable does not emit a warning.": "当值为假值或变量不存在时返回 true。检查未定义的变量不会产生警告。", + "Returns true only when every supplied variable exists and is not null. Checks stop at the first unset or null variable.": "仅当传入的每个变量都存在且不为 null 时返回 true。遇到第一个未设置或为 null 的变量即停止检查。" }