style: Add pre-commit hook to check doxygen style (#7794)

This commit is contained in:
Ayaz Salikhov
2026-07-13 14:27:14 +01:00
committed by GitHub
parent 73b6852a12
commit 62634463f4
3 changed files with 436 additions and 0 deletions

View File

@@ -302,6 +302,7 @@ words:
- takerpays
- ters
- TMEndpointv2
- tparam
- trixie
- tx
- txid

View File

@@ -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

View File

@@ -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())