feat(editor): insert a documentation comment stub on Enter after /**

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.
This commit is contained in:
l0ng-ai
2026-11-06 01:14:11 +08:00
parent 07df51f308
commit 67b5b2def7
8 changed files with 178 additions and 1 deletions
+3
View File
@@ -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(),
}
}
+2
View File
@@ -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,
};
+77
View File
@@ -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);
});
});
+88
View File
@@ -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;
};
}
+2
View File
@@ -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 });
@@ -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",
+1
View File
@@ -152,6 +152,7 @@ export const EDITOR_EXTRA_DEFAULTS = {
tabOutOfPair: true,
reformatOnPaste: "indentEachLine",
smartIndent: true,
insertDocStub: true,
backspaceUnindent: "autoindent",
},
codeEditing: {
+1
View File
@@ -2175,6 +2175,7 @@
"Smart Step Into": "智能步入",
"Smart Step Into failed": "智能步入失败",
"Smart indent": "智能缩进",
"Insert documentation comment stub": "插入文档注释存根",
"Soft": "软",
"Soft Wraps": "软换行",
"Soft-Wrap": "软换行",