From 67b5b2def7396e3289eede8ef878f065660db8fb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:14:11 +0800 Subject: [PATCH] feat(editor): insert a documentation comment stub on Enter after /** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter after a /** that opens a new comment now closes it with the caret on the summary line, in every language with /* */ comments. Above a PHP function or method the stub lists its parameters (@param, with the declared type and variadic ...) and the declared return type (@return; none for void and constructors), as PhpStorm does. Settings › Editor › General › Smart Keys › Enter › Insert documentation comment stub turns it off. --- crates/ide-core/src/editor_settings.rs | 3 + src/bindings.ts | 2 + src/editor/core/cm/docComment.test.ts | 77 ++++++++++++++++ src/editor/core/cm/docComment.ts | 88 +++++++++++++++++++ src/editor/core/cm/prefs.ts | 2 + .../settings/pages/editor/general.tsx | 5 +- src/platform/editorSettings.ts | 1 + src/platform/locales/text/zh.json | 1 + 8 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/editor/core/cm/docComment.test.ts create mode 100644 src/editor/core/cm/docComment.ts diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 4806bf2c..c5b32ef9 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -252,6 +252,8 @@ pub struct SmartKeysSettings { /// Reformat on paste: `none` | `indentBlock` | `indentEachLine`. pub reformat_on_paste: String, pub smart_indent: bool, + /// Enter › Insert documentation comment stub: Enter after `/**` closes the comment. + pub insert_doc_stub: bool, /// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. pub backspace_unindent: String, } @@ -267,6 +269,7 @@ impl Default for SmartKeysSettings { tab_out_of_pair: true, reformat_on_paste: "indentEachLine".into(), smart_indent: true, + insert_doc_stub: true, backspace_unindent: "autoindent".into(), } } diff --git a/src/bindings.ts b/src/bindings.ts index aa8eee5e..902eda11 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -6870,6 +6870,8 @@ export type SmartKeysSettings = { /** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */ reformatOnPaste?: string, smartIndent?: boolean, + /** Enter › Insert documentation comment stub: Enter after `/**` closes the comment. */ + insertDocStub?: boolean, /** Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. */ backspaceUnindent?: string, }; diff --git a/src/editor/core/cm/docComment.test.ts b/src/editor/core/cm/docComment.test.ts new file mode 100644 index 00000000..a0a1a7df --- /dev/null +++ b/src/editor/core/cm/docComment.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { undo, history } from "@codemirror/commands"; +import { enterInDocComment, phpDocTags } from "./docComment"; + +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((v) => v.destroy())); + +const slashStar = EditorState.languageData.of(() => [ + { commentTokens: { line: "//", block: { open: "/*", close: "*/" } } }, +]); +const hash = EditorState.languageData.of(() => [{ commentTokens: { line: "#" } }]); + +/** `|` marks the caret. */ +function enter(text: string, language = "php", tokens = slashStar) { + const at = text.indexOf("|"); + const view = new EditorView({ + state: EditorState.create({ + doc: text.replace("|", ""), + selection: EditorSelection.cursor(at), + extensions: [tokens, history()], + }), + }); + views.push(view); + const handled = enterInDocComment(language)(view); + const s = view.state; + const caret = s.selection.main.head; + return { handled, view, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) }; +} + +describe("Enter after /**", () => { + it("lists a PHP function's parameters and return type, caret on the summary line", () => { + const r = enter("/**|\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}"); + expect(r.handled).toBe(true); + expect(r.text).toBe( + "/**\n * |\n * @param int $a\n * @param $b\n * @return string\n */\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}", + ); + undo(r.view); + expect(r.view.state.doc.toString()).toBe( + "/**\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}", + ); + }); + + it("uses the indentation of the /** line inside a class", () => { + expect( + enter("class A {\n /**|\n public static function make(?array $o = [], string ...$rest): ?static {}\n}") + .text, + ).toBe( + "class A {\n /**\n * |\n * @param ?array $o\n * @param string ...$rest\n * @return ?static\n */\n public static function make(?array $o = [], string ...$rest): ?static {}\n}", + ); + }); + + it("gives constructors and void methods no @return; promoted parameters keep their type", () => { + expect(phpDocTags("public function __construct(private int $a, #[Attr] readonly Foo&Bar $b) {}")).toEqual([ + "@param int $a", + "@param Foo&Bar $b", + ]); + expect(phpDocTags("function run(array &$x = array(1, 2), &$y): void {}")).toEqual(["@param array $x", "@param $y"]); + expect(phpDocTags("abstract protected function find(int|string $id): ?Model;")).toEqual([ + "@param int|string $id", + "@return ?Model", + ]); + }); + + it("closes the comment with an empty summary above anything else, and in other /* */ languages", () => { + expect(enter("/**|\nclass A {}").text).toBe("/**\n * |\n */\nclass A {}"); + expect(enter(" /**|\n\nconst x = 1;", "javascript").text).toBe(" /**\n * |\n */\n\nconst x = 1;"); + }); + + it("only breaks the line in an existing doc block, after other text, or without /* */ comments", () => { + expect(enter("/**|\n * old\n */\nfunction f() {}").handled).toBe(false); + expect(enter("$a = 1; /**|").handled).toBe(false); + expect(enter("/** x|").handled).toBe(false); + expect(enter("/**|", "python", hash).handled).toBe(false); + }); +}); diff --git a/src/editor/core/cm/docComment.ts b/src/editor/core/cm/docComment.ts new file mode 100644 index 00000000..80f068a2 --- /dev/null +++ b/src/editor/core/cm/docComment.ts @@ -0,0 +1,88 @@ +/** + * Smart Keys › Enter › Insert documentation comment stub: Enter right after a `/**` that opens a + * new comment closes it, the caret on the summary line. In PHP the stub of a function lists its + * parameters (`@param`) and return type (`@return`), as PhpStorm does. + */ +import { EditorSelection } from "@codemirror/state"; +import type { Command } from "@codemirror/view"; + +/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */ +export function phpDocTags(text: string): string[] { + const m = + /^\s*(?:#\[[^\]]*\]\s*)*(?:(?:abstract|final|public|protected|private|static)\s+)*function\s+&?\s*(\w+)\s*\(/.exec( + text, + ); + if (!m) return []; + let depth = 1; + let i = m[0].length; + for (; i < text.length && depth > 0; i++) { + const c = text[i]; + if (c === "(" || c === "[") depth++; + else if (c === ")" || c === "]") depth--; + } + if (depth > 0) return []; + const tags = splitTopLevel(text.slice(m[0].length, i - 1)).flatMap((param) => { + const p = /^(?:#\[[^\]]*\]\s*)*(?:(?:public|protected|private|readonly)\s+)*([^$=.]*?)\s*(\.\.\.)?\s*(\$\w+)/.exec( + param.trim(), + ); + if (!p) return []; + // `&$x` passes by reference: the `&` belongs to no type (`Foo&Bar` is an intersection). + const type = p[1]!.replace(/&\s*$/, "").trim().replace(/\s+/g, " "); + const name = (p[2] ?? "") + p[3]; + return [type ? `@param ${type} ${name}` : `@param ${name}`]; + }); + const ret = /^\s*:\s*([?\w\\|&() ]+?)\s*(?:\{|;|$)/.exec(text.slice(i)); + const type = ret?.[1]?.trim(); + if (type && type !== "void" && m[1] !== "__construct") tags.push(`@return ${type}`); + return tags; +} + +function splitTopLevel(params: string): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < params.length; i++) { + const c = params[i]; + if (c === "(" || c === "[") depth++; + else if (c === ")" || c === "]") depth--; + else if (c === "," && depth === 0) { + out.push(params.slice(start, i)); + start = i + 1; + } + } + out.push(params.slice(start)); + return out.filter((p) => p.trim()); +} + +/** The Enter command for documents of `language` (stub tags in PHP). */ +export function enterInDocComment(language: string | undefined): Command { + return (view) => { + const { state } = view; + if (state.readOnly || state.selection.ranges.length !== 1) return false; + const r = state.selection.main; + if (!r.empty) return false; + const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block; + if (block?.open !== "/*" || block.close !== "*/") return false; + const line = state.doc.lineAt(r.head); + const before = line.text.slice(0, r.head - line.from); + if (before.trim() !== "/**" || line.text.slice(r.head - line.from).trim()) return false; + // A `/**` that already starts a doc block (the next line continues it) only breaks the line. + const next = line.number < state.doc.lines ? state.doc.line(line.number + 1).text.trimStart() : ""; + if (next.startsWith("*")) return false; + const indent = /^[ \t]*/.exec(before)![0]; + const br = state.lineBreak; + const below = line.number < state.doc.lines ? state.sliceDoc(state.doc.line(line.number + 1).from) : ""; + const tags = language === "php" ? phpDocTags(below.slice(0, 4000)) : []; + const summary = `${br}${indent} * `; + const insert = summary + tags.map((t) => `${br}${indent} * ${t}`).join("") + `${br}${indent} */`; + view.dispatch( + state.update({ + changes: { from: r.head, insert }, + selection: EditorSelection.cursor(r.head + summary.length), + scrollIntoView: true, + userEvent: "input", + }), + ); + return true; + }; +} diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 43501431..04edc133 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -48,6 +48,7 @@ import { selectHumpRight, } from "./editing"; import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments"; +import { enterInDocComment } from "./docComment"; import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; // --- soft wraps --------------------------------------------------------------- @@ -571,6 +572,7 @@ export function prefsExtension(o: PrefsOptions): Extension { { key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true }, { mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true }, ); + if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) }); if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment }); if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent }); if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict }); diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index a34ac6cf..1a862149 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -677,7 +677,10 @@ const SMART_KEYS = editorPage({ }, { title: "Enter", - items: [{ t: "check", key: `${P}.smartKeys.smartIndent`, label: "Smart indent" }], + items: [ + { t: "check", key: `${P}.smartKeys.smartIndent`, label: "Smart indent" }, + { t: "check", key: `${P}.smartKeys.insertDocStub`, label: "Insert documentation comment stub" }, + ], }, { title: "Backspace", diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 79b37e14..b76f97c5 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -152,6 +152,7 @@ export const EDITOR_EXTRA_DEFAULTS = { tabOutOfPair: true, reformatOnPaste: "indentEachLine", smartIndent: true, + insertDocStub: true, backspaceUnindent: "autoindent", }, codeEditing: { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4bb0f324..ccb01471 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2175,6 +2175,7 @@ "Smart Step Into": "智能步入", "Smart Step Into failed": "智能步入失败", "Smart indent": "智能缩进", + "Insert documentation comment stub": "插入文档注释存根", "Soft": "软", "Soft Wraps": "软换行", "Soft-Wrap": "软换行",