From 62634463f4adbe21d77206c75d0d7201e301e6fa Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Mon, 13 Jul 2026 14:27:14 +0100 Subject: [PATCH] style: Add pre-commit hook to check doxygen style (#7794) --- .cspell.config.yaml | 1 + .pre-commit-config.yaml | 5 + bin/pre-commit/check_doxygen_style.py | 430 ++++++++++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100755 bin/pre-commit/check_doxygen_style.py diff --git a/.cspell.config.yaml b/.cspell.config.yaml index dc24bf833b..3c5191f5e1 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -302,6 +302,7 @@ words: - takerpays - ters - TMEndpointv2 + - tparam - trixie - tx - txid diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 910bda8d4c..d3bf0209fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,6 +43,11 @@ repos: language: python entry: ./bin/pre-commit/fix_pragma_once.py files: \.(h|hpp)$ + - id: check-doxygen-style + name: check Doxygen comment style + entry: ./bin/pre-commit/check_doxygen_style.py + language: python + types_or: [c++, c] - repo: https://github.com/pre-commit/mirrors-clang-format rev: dd18dad857d6133e90bbe478f4f2f22ec0030269 # frozen: v22.1.5 diff --git a/bin/pre-commit/check_doxygen_style.py b/bin/pre-commit/check_doxygen_style.py new file mode 100755 index 0000000000..fd37450113 --- /dev/null +++ b/bin/pre-commit/check_doxygen_style.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +""" +Check C++ Doxygen comment style. + +Enforces the house convention for documentation comments: + + * Use ``/** ... */`` blocks, not ``///``, ``//!`` or ``/*! ... */``; a plain + ``/* ... */`` that contains Doxygen commands is a doc comment missing its + second star. Trailing member-after comments use ``///<`` (not ``//!<`` or + ``/**< ... */`` -- the block form gets reflowed/mis-attached by + clang-format on packed enum values, the line form does not). + * ``/**`` sits alone on its line; the closing ``*/`` sits alone on its line. + * Every content line is prefixed with `` * `` (no bare-indented continuation). + * The first content line is flush (not over-indented). + * Doxygen commands use the ``@cmd`` form, not ``\\cmd``. + * Use ``@return`` / ``@throws`` rather than prose ``Returns:`` / ``Throws:``. + * A plain ``//`` comment carrying a block-level ``@command`` (``@param``, + ``@return``, ``@see``, ...) is documentation and must be a ``/** ... */`` + block (Doxygen ignores ``//``). + * Use canonical command spellings: ``@return`` (not ``@returns``), + ``@throws`` (not ``@throw``), ``@see`` (not ``@sa``). + * Order block tags ``@tparam`` -> ``@param`` -> ``@return``. (Whether + ``@param`` order matches the signature is not checked here -- too fragile to + parse; Doxygen's WARN_IF_DOC_ERROR covers name mismatches.) + * One-liners are expanded to three lines, EXCEPT bare markers ``@{`` / ``@}`` + / ``@cond [label]`` / ``@endcond`` / ``@file [name]`` which stay on one line. + +Left intentionally alone (recognized, valid Doxygen that is not this style's +concern): + + * ``///<`` trailing "member-after" comments (the house form). + * Divider lines made only of slashes (``//////////``). + * Plain ``/* ... */`` (non-Doxygen) comments. + +Usage: + check_doxygen_style.py [FILE ...] # explicit files + check_doxygen_style.py # default: src/ and include/ trees + +Exit status is non-zero if any violation is found. +""" + +import argparse +import re +import sys +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + + +class Category(Enum): + """A kind of style violation: a printed ``label`` and its ``description``. + + The description is the default message; a few categories whose wording + depends on the offending text (see ``Finding.detail``) override it. + """ + + def __init__(self, label: str, description: str) -> None: + self.label = label + self.description = description + + BACKSLASH_COMMAND = ("backslash-command", "use the @cmd form, not \\cmd") + WRONG_COMMAND = ("wrong-command", "use the canonical command spelling") + TRIPLE_SLASH = ("triple-slash", "use a /** ... */ block instead of ///") + QT_MEMBER = ("qt-member", "use ///< instead of //!<") + QT_LINE = ("qt-line", "use a /** ... */ block instead of //!") + BLOCK_MEMBER = ("block-member", "use ///< instead of /**<") + DOC_IN_LINE_COMMENT = ( + "doc-in-line-comment", + "use a /** ... */ block for documentation, not //", + ) + QT_COMMENT = ("qt-comment", "use /** instead of /*!") + SINGLE_LINE_BLOCK = ( + "single-line-block", + "expand one-line /** ... */ to a multi-line block " + "(markers @{ @} @cond @endcond @file may stay)", + ) + TEXT_ON_OPENER = ("text-on-opener", "move text off the /** opener line") + BARE_CONTINUATION = ("bare-continuation", 'prefix continuation lines with " * "') + OVER_INDENTED = ("over-indented", "first content line is over-indented") + OVER_INDENTED_TAG = ( + "over-indented-tag", + 'Doxygen tag over-indented; use a single space after "*"', + ) + COMBINED_MARKER = ( + "combined-marker", + "scope marker @{ / @} should be its own single-line /** @{ */ block", + ) + PROSE_LABEL = ("prose-label", "use a Doxygen tag instead of a prose label") + CONTENT_ON_CLOSER = ("content-on-closer", "move content off the closing */ line") + PLAIN_BLOCK_DOC = ( + "plain-block-doc", + "documentation comment must open with /** not /*", + ) + TAG_ORDER = ( + "tag-order", + "block tags out of order; expected @tparam, then @param, then @return", + ) + + +@dataclass(frozen=True) +class Finding: + """A single style violation at a 1-based line number. + + ``detail`` overrides the category's default description when the message + depends on the offending text (e.g. which command was misspelled). + """ + + line: int + category: Category + detail: str | None = None + + @property + def message(self) -> str: + return self.detail if self.detail is not None else self.category.description + + +DEFAULT_ROOTS = ("src", "include") +EXTS = {".h", ".hpp", ".cpp", ".ipp", ".cxx", ".cc"} + +# Every Doxygen command we recognize when written with a backslash (\cmd). +_ALL_COMMANDS = ( + "brief|param|tparam|return|returns|retval|note|warning|pre|post|see|sa|ref|" + "throw|throws|exception|deprecated|details|code|endcode|verbatim|endverbatim|" + "li|arg|c|internal|since|todo|attention|remark|remarks|ingroup|defgroup" +) +# Block-level tags whose over-indentation we flag inside a block body. +_BLOCK_TAGS = ( + "param|tparam|returns?|retval|brief|throws?|note|warning|" + "pre|post|see|sa|details|deprecated" +) +# Tags that, appearing anywhere in a comment, mark it as documentation. +_ANY_DOC_TAGS = ( + "param|tparam|returns?|retval|brief|throws?|note|warning|pre|post|see|sa" +) +# Tags that make a plain // comment a mis-styled doc comment. +_LINE_DOC_TAGS = "brief|param|tparam|returns?|retval|throws?|note|see|pre|post" + +# \cmd that should be @cmd. +RE_BACKSLASH_CMD = re.compile(r"\\(" + _ALL_COMMANDS + r")\b") +# Bare markers that may legitimately stay on a single line. +RE_MARKER = re.compile(r"^@(\{|\}|cond(\s.*)?|endcond|file(\s.*)?)$") +# Prose section labels that should be Doxygen tags. +RE_PROSE_LABEL = re.compile(r"^\*\s(Returns|Throws|Exceptions):\s*$") +# An over-indented block tag: "*" followed by 2+ spaces then the tag. +RE_OVERINDENTED_TAG = re.compile(r"^\*\s{2,}@(" + _BLOCK_TAGS + r")\b") +# Any documentation tag (used to spot a doc comment hiding in a plain /* */). +RE_ANY_DOC_TAG = re.compile(r"@(" + _ANY_DOC_TAGS + r")\b") +# A documentation tag inside a // comment. +RE_LINE_DOC_TAG = re.compile(r"@(" + _LINE_DOC_TAGS + r")\b") +# Order-relevant tags, for the @tparam -> @param -> @return ordering check. +RE_ORDER_TAG = re.compile(r"^\*\s*@(param|tparam|returns?|retval)\b") +# First content line indented by 2+ spaces after the "*". +RE_FIRST_OVERINDENT = re.compile(r"^\s*\*\s{2,}\S") +# A scope marker @{ / @} sharing a comment with other text. +RE_COMBINED_MARKER = re.compile(r"^\*\s*@[{}]\s*$") + +# Non-canonical command spellings -> house spelling. +WRONG_SPELLINGS = [ + (re.compile(r"@returns\b"), "@return"), + (re.compile(r"@throw\b"), "@throws"), + (re.compile(r"@sa\b"), "@see"), +] + +# Order block tags should appear in; a body out of this order is a violation. +EXPECTED_TAG_ORDER = ("tparam", "param", "return") + + +def is_doxy_open(stripped: str) -> bool: + """True for a line-start Doxygen block opener we should normalize.""" + if stripped.startswith("/*!"): # Qt-style Doxygen + return not stripped.startswith("/*!<") # member-after, leave inline + return ( + stripped.startswith("/**") + and not stripped.startswith("/***") + and not stripped.startswith("/**/") + and not stripped.startswith("/**<") + ) + + +def _flag_commands(raw_line: str, stripped: str, index: int) -> list[Finding]: + """Non-terminal: flag \\cmd and misspelled @cmd on any comment-ish line.""" + if not stripped.startswith(("*", "//", "/*")): + return [] + findings: list[Finding] = [] + backslash = RE_BACKSLASH_CMD.search(raw_line) + if backslash: + command = backslash.group(1) + findings.append( + Finding( + index + 1, + Category.BACKSLASH_COMMAND, + f"use @{command} instead of \\{command}", + ) + ) + for pattern, replacement in WRONG_SPELLINGS: + wrong = pattern.search(raw_line) + if wrong: + findings.append( + Finding( + index + 1, + Category.WRONG_COMMAND, + f"use {replacement} instead of {wrong.group(0)}", + ) + ) + return findings + + +def _flag_line_comment(raw_line: str, stripped: str, index: int) -> Finding | None: + """Return the finding for a terminal single-line comment form, else None.""" + if stripped.startswith("///") and not stripped.startswith(("////", "///<")): + return Finding(index + 1, Category.TRIPLE_SLASH) + if "//!<" in raw_line: + return Finding(index + 1, Category.QT_MEMBER) + if stripped.startswith("//!"): + return Finding(index + 1, Category.QT_LINE) + if "/**<" in raw_line: + return Finding(index + 1, Category.BLOCK_MEMBER) + if stripped.startswith("//") and RE_LINE_DOC_TAG.search(stripped): + return Finding(index + 1, Category.DOC_IN_LINE_COMMENT) + return None + + +def _flag_single_line_block(stripped: str, line_no: int, is_qt: bool) -> list[Finding]: + """Findings for a whole /** ... */ or /*! ... */ block on one line.""" + inner = re.sub(r"^/\*[*!]", "", stripped) + inner = re.sub(r"\*/\s*$", "", inner).strip() + findings: list[Finding] = [] + if is_qt: + findings.append(Finding(line_no, Category.QT_COMMENT)) + if inner and not RE_MARKER.match(inner): + findings.append(Finding(line_no, Category.SINGLE_LINE_BLOCK)) + return findings + + +def _canonical_order_tag(body: str) -> str | None: + """The order-relevant tag (tparam/param/return) a body line opens with, if any.""" + match = RE_ORDER_TAG.match(body) + if match is None: + return None + command = match.group(1) + return "return" if command in ("return", "returns", "retval") else command + + +def _flag_body_line( + body_line: str, line_no: int, is_first_content: bool +) -> list[Finding]: + """Findings for one interior line of a multi-line block.""" + body = body_line.strip() + findings: list[Finding] = [] + if body and not body.startswith("*"): + findings.append(Finding(line_no, Category.BARE_CONTINUATION)) + if body.startswith("*"): + if is_first_content and RE_FIRST_OVERINDENT.match(body_line): + findings.append(Finding(line_no, Category.OVER_INDENTED)) + if RE_OVERINDENTED_TAG.match(body): + findings.append(Finding(line_no, Category.OVER_INDENTED_TAG)) + if RE_COMBINED_MARKER.match(body): + findings.append(Finding(line_no, Category.COMBINED_MARKER)) + label = RE_PROSE_LABEL.match(body) + if label: + suggested_tag = "@return" if label.group(1) == "Returns" else "@throws" + findings.append( + Finding( + line_no, + Category.PROSE_LABEL, + f'use {suggested_tag} instead of prose "{label.group(1)}:"', + ) + ) + return findings + + +def _flag_closer(closer_line: str, line_no: int) -> list[Finding]: + """Findings for content sharing the closing */ line.""" + before = closer_line[: closer_line.index("*/")].strip() + if before and before != "*": + return [Finding(line_no, Category.CONTENT_ON_CLOSER)] + return [] + + +def _flag_tag_order(first_tag_line: dict[str, int]) -> list[Finding]: + """One finding if the present block tags are not in EXPECTED_TAG_ORDER.""" + tag_lines = [ + first_tag_line[tag] for tag in EXPECTED_TAG_ORDER if tag in first_tag_line + ] + if tag_lines != sorted(tag_lines): + return [Finding(min(tag_lines), Category.TAG_ORDER)] + return [] + + +def _flag_doxy_block(lines: list[str], start: int) -> tuple[int, list[Finding]]: + """Handle a /** or /*! block opening at ``start``; return (next index, findings).""" + raw_line = lines[start] + stripped = raw_line.lstrip() + open_pos = raw_line.index("/*") + is_qt = stripped.startswith("/*!") + + # A whole block on one line: /** ... */. + if "*/" in raw_line[open_pos + 2 :]: + return start + 1, _flag_single_line_block(stripped, start + 1, is_qt) + + # Multi-line block: opener, then scan the body to the closer. + findings: list[Finding] = [] + if is_qt: + findings.append(Finding(start + 1, Category.QT_COMMENT)) + if raw_line[open_pos + 3 :].strip(): + findings.append(Finding(start + 1, Category.TEXT_ON_OPENER)) + + line_count = len(lines) + cursor = start + 1 + is_first_content = True + first_tag_line: dict[str, int] = {} # canonical tag -> 1-based first line + while cursor < line_count and "*/" not in lines[cursor]: + body_line = lines[cursor] + body = body_line.strip() + findings.extend(_flag_commands(body_line, body, cursor)) + tag = _canonical_order_tag(body) + if tag is not None: + first_tag_line.setdefault(tag, cursor + 1) + findings.extend(_flag_body_line(body_line, cursor + 1, is_first_content)) + if body.startswith("*"): + is_first_content = False + cursor += 1 + + if cursor < line_count: + closer_line = lines[cursor] + findings.extend(_flag_commands(closer_line, closer_line.strip(), cursor)) + findings.extend(_flag_closer(closer_line, cursor + 1)) + findings.extend(_flag_tag_order(first_tag_line)) + + return cursor + 1, findings + + +def _flag_plain_block(lines: list[str], start: int) -> tuple[int, list[Finding]]: + """Handle a line-start plain /* ... */ block; return (next index, findings). + + Only flagged when it hides a documentation command (a missing second star). + """ + line_count = len(lines) + cursor = start + while cursor < line_count and "*/" not in lines[cursor]: + cursor += 1 + findings: list[Finding] = [] + # The opener (start) is command-checked by check_file; check the rest here. + for i in range(start + 1, min(cursor + 1, line_count)): + findings.extend(_flag_commands(lines[i], lines[i].strip(), i)) + block_text = "\n".join( + lines[start : cursor + 1] if cursor < line_count else lines[start:] + ) + if RE_ANY_DOC_TAG.search(block_text): + findings.append(Finding(start + 1, Category.PLAIN_BLOCK_DOC)) + next_index = cursor + 1 if cursor < line_count else line_count + return next_index, findings + + +def check_file(path: Path) -> list[Finding]: + """Return all style violations found in one file.""" + lines = path.read_text(encoding="utf-8").split("\n") + findings: list[Finding] = [] + line_count = len(lines) + index = 0 + in_plain_block = False # inside a mid-line, non-Doxygen /* ... */ + while index < line_count: + raw_line = lines[index] + stripped = raw_line.lstrip() + + # Skip the interior of a plain block opened on an earlier line. + if in_plain_block: + in_plain_block = "*/" not in raw_line + index += 1 + continue + + findings.extend(_flag_commands(raw_line, stripped, index)) + + line_finding = _flag_line_comment(raw_line, stripped, index) + if line_finding is not None: + findings.append(line_finding) + index += 1 + elif is_doxy_open(stripped): + index, block_findings = _flag_doxy_block(lines, index) + findings.extend(block_findings) + elif stripped.startswith("/*"): + index, block_findings = _flag_plain_block(lines, index) + findings.extend(block_findings) + else: + # A /* that opens mid-line without closing starts a plain block. + if "/*" in raw_line and not stripped.startswith("//"): + if "*/" not in raw_line[raw_line.index("/*") + 2 :]: + in_plain_block = True + index += 1 + return findings + + +def iter_files(paths: Iterable[str]) -> Iterator[Path]: + """Yield every C++ source file among the given files and directories.""" + for raw_path in paths: + path = Path(raw_path) + if path.is_dir(): + for candidate in path.rglob("*"): + if candidate.is_file() and candidate.suffix in EXTS: + yield candidate + elif path.suffix in EXTS: + yield path + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check Doxygen comment style.") + parser.add_argument( + "files", nargs="*", help="files or directories (default: src/ include/)" + ) + parser.add_argument( + "-q", "--quiet", action="store_true", help="only print the summary count" + ) + args = parser.parse_args() + roots = args.files or [root for root in DEFAULT_ROOTS if Path(root).is_dir()] + + total = 0 + for path in sorted(set(iter_files(roots)), key=str): + for finding in check_file(path): + total += 1 + if not args.quiet: + print( + f"{path}:{finding.line}: {finding.category.label}: {finding.message}" + ) + print(f"\n{total} doc-style violation(s)", file=sys.stderr) + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main())