/** * 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; }; }