ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_comment_format.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate + fixer: house style for single-line C/C++ block comments.
5
6clang-format owns the *start* column of trailing comments (``AlignTrailing
7Comments: true`` aligns each block's ``/**<`` to the widest code + one space)
8but never touches the comment *interior* (``ReflowComments: false``). This
9pass runs *after* clang-format and owns the interior, normalising three things:
10
111. **Space after the opener.** ``/*foo`` -> ``/* foo`` (the Doxygen forms
12 ``/**`` / ``/**<`` likewise gain a single space before their text).
132. **Space before the closer.** ``foo.*/`` -> ``foo. */`` -- one space before
14 ``*/`` unless the comment is widened for end alignment (rule 3).
153. **Aligned ``*/`` end column.** Across a run of consecutive trailing comments
16 that clang-format placed in the *same start column*, the closers are padded
17 to line up under the longest (which gets one space before ``*/``). A run is
18 broken by a blank line, a code-only line, a standalone comment, a change of
19 start column, or where padding would reach the column limit (which would make
20 clang-format collapse its leading alignment). Standalone full-line comments
21 are spacing-normalised but never end-aligned.
224. **One block, one alignment.** Rule 3 *accommodates* a torn block; this rule
23 forbids one. ``AlignTrailingComments`` aligns a run to its widest code plus
24 one space but abandons that column when the longest comment would cross the
25 limit, leaving one struct with two ``/**<`` columns and two ``*/`` columns.
26 That is canonical clang-format output, so neither tool objects to it, and
27 ragged blocks accumulated in the tree unremarked. A run must therefore align
28 as a single unit. This rule is REPORTED, never fixed: the remedy is to
29 shorten the comment or move it to its own ``/** ... */`` block above the line
30 it documents, and neither is a rewrite a formatter may make on its own. A
31 reported block IS exempted from rule 3, though -- end-padding is a grouping
32 hint clang-format honours, so padding a torn block to its two sub-widths
33 would keep it torn even after the author shortened the comment that tore it.
34
35The text in front of each comment -- code plus clang-format's leading alignment
36-- is preserved verbatim, so the two tools never fight: clang owns the start
37column, this pass owns the interior + the ``*/`` column.
38
39Only **single-line** block comments are touched (opener and matching ``*/`` on
40the same physical line). Multi-line ``/** ... */`` blocks, ``//`` line
41comments, string / char / raw-string literals, decorative banners, and inline
42mid-code comments (``f(/*tag=*/x)``) are left byte-for-byte alone. The pass is
43idempotent (re-running ``--fix`` is a no-op) and clang-format-stable (a
44clang-format re-run leaves the result alone).
45
46Run::
47
48 check_comment_format.py # gate the whole tree (exit 1 on drift)
49 check_comment_format.py path ... # gate only the listed files/dirs
50 check_comment_format.py --fix [path...] # rewrite in place
51 check_comment_format.py --selftest # run the built-in test battery
52
53Exit 0 if clean (or fixed), exit 1 on findings in check mode, exit 2 on a
54selftest failure or a whole-tree sweep that collapsed below FILE_FLOOR.
55"""
56
57from __future__ import annotations
58
59import sys
60from collections.abc import Iterable
61from pathlib import Path
62from typing import NamedTuple
63
64sys.path.insert(0, str(Path(__file__).resolve().parent))
65
66from lint_targets import is_build_output_path
67
68REPO_ROOT = Path(__file__).resolve().parents[2]
69
70SOURCE_SUFFIXES = (
71 ".c",
72 ".h",
73 ".cpp",
74 ".hpp",
75 ".cc",
76 ".cxx",
77 ".hh",
78 ".hxx",
79 ".m",
80 ".mm",
81 ".inl",
82)
83# Mirror scripts/checks/format_code.sh's scope: this pass runs *after* clang-format and
84# only makes sense where clang-format already aligned the comment starts.
85# format_code.sh formats libs/ tests/ examples/ tools/ (not port/), so the
86# no-argument scan here matches; format_code.sh also drives this tool with an
87# explicit file list, which is the authoritative gate path.
88SCAN_ROOTS = ("libs", "port", "examples", "tools", "apps", "tests")
89EXCLUDE_FRAGMENTS = (
90 "libs/third_party/",
91 "apps/shared_libs/third_party/",
92 "libs/ra8_fonts/",
93 "port/threadx/",
94)
95
96# A tree this size cannot legitimately collapse to a handful of files. If the
97# whole-tree sweep returns less than this, something broke (an unreachable repo
98# root, a renamed SCAN_ROOTS entry) and reporting "comments well-formed" would
99# be a lie -- the old `no files to scan` branch exited 0 on exactly that.
100# Measured 2026-07-28: 2124 first-party C/C++ sources. Same trip-wire as
101# check_ruff.py.
102FILE_FLOOR = 1700
103
104# Scan-state for the per-line C/C++ tokeniser.
105_ST_NORMAL = 0
106_ST_BLOCK = 1 # inside a multi-line /* ... */ block comment
107_ST_RAW = 2 # inside a C++ raw string R"delim( ... )delim"
108
109# Must match .clang-format ColumnLimit. End-alignment never pads a comment PAST
110# this column: a run splits instead, because a trailing comment that overruns the
111# limit makes clang-format collapse its (clang-owned) leading alignment to fit,
112# which would fight this pass.
113#
114# The bound is inclusive. ColumnLimit permits a line of exactly this width --
115# clang-format-22 leaves a 100-column trailing comment alone and only wraps the
116# declaration at 101 -- so a guard that stopped one short of it tore blocks in
117# two for no reason, which is how 47 of them ended up in the tree.
118_COLUMN_LIMIT = 100
119
120# How many offending lines a split-run finding names before eliding the rest.
121_MAX_NAMED_LINES = 6
122
123# A line opening with one of these is never part of clang-format's trailing
124# comment alignment sequence: a scope-closing brace, or a preprocessor
125# directive.
126_OUT_OF_SEQUENCE = ("}", "#")
127
128# One trailing comment is a run of one, and a run of one always aligns.
129_MIN_RUN = 2
130
131
132class _Comment(NamedTuple):
133 """One single-line block comment found in code (NORMAL) context.
134
135 `start`/`end` are character indices into the physical line such that
136 ``line[start:end]`` is the full ``/* ... */`` text. `opener` is the
137 normalised opener token (``/*`` / ``/**`` / ``/**<`` / ``/*!`` / ``/*!<``)
138 and `content` is the inner prose with leading/trailing whitespace stripped
139 and internal whitespace preserved. `processable` is False for banners /
140 empties that must be left untouched.
141 """
142
143 start: int
144 end: int
145 opener: str
146 content: str
147 processable: bool
148
149
150def _classify(text: str) -> tuple[str, str, bool]:
151 """Split a ``/* ... */`` comment into (opener, content, processable).
152
153 `text` is the full comment including the ``/*`` and ``*/`` delimiters.
154 Returns the normalised opener token, the stripped inner content, and a
155 processability flag that is False for empty comments and decorative
156 banners (which are returned verbatim by the caller).
157 """
158 inner = text[2:-2] # drop the leading /* and trailing */
159 if not inner.strip():
160 return ("/*", inner, False)
161 if "\t" in inner:
162 return ("/*", inner, False) # tabs break column math -- leave alone
163
164 if inner.startswith("*<"):
165 opener, rest = "/**<", inner[2:]
166 elif inner.startswith("!<"):
167 opener, rest = "/*!<", inner[2:]
168 elif inner.startswith("**"):
169 # /***... banner -- not a Doxygen block; leave untouched.
170 return ("/*", inner, False)
171 elif inner.startswith("*"):
172 opener, rest = "/**", inner[1:]
173 elif inner.startswith("!"):
174 opener, rest = "/*!", inner[1:]
175 else:
176 opener, rest = "/*", inner
177
178 content = rest.strip()
179 if not content:
180 return (opener, content, False)
181 # Decorative leading/trailing asterisks (banners, dividers): leave alone.
182 if content.startswith("*") or content.endswith("*"):
183 return (opener, content, False)
184 return (opener, content, True)
185
186
187def _scan_line(line: str, state: int, raw_delim: str) -> tuple[list[_Comment], int, str]:
188 """Tokenise one line, returning (single-line comments, new_state, raw_delim).
189
190 Tracks string/char/raw-string literals, ``//`` line comments, and multi-line
191 ``/* */`` blocks so that ``/*`` and ``*/`` sequences inside literals or other
192 comments are never mistaken for a comment delimiter.
193 """
194 comments: list[_Comment] = []
195 i = 0
196 n = len(line)
197
198 if state == _ST_BLOCK:
199 end = line.find("*/")
200 if end == -1:
201 return (comments, _ST_BLOCK, raw_delim)
202 i = end + 2
203 elif state == _ST_RAW:
204 close = ")" + raw_delim + '"'
205 end = line.find(close)
206 if end == -1:
207 return (comments, _ST_RAW, raw_delim)
208 i = end + len(close)
209
210 while i < n:
211 c = line[i]
212 nxt = line[i + 1] if i + 1 < n else ""
213
214 if c == "/" and nxt == "/":
215 break # line comment: rest of the line is not code
216 if c == "/" and nxt == "*":
217 close = line.find("*/", i + 2)
218 if close == -1:
219 return (comments, _ST_BLOCK, raw_delim) # opens a multi-line block
220 text = line[i : close + 2]
221 opener, content, processable = _classify(text)
222 comments.append(_Comment(i, close + 2, opener, content, processable))
223 i = close + 2
224 continue
225 if c == '"':
226 delim = _raw_string_delim(line, i)
227 if delim is not None:
228 close = ")" + delim + '"'
229 end = line.find(close, i + 2 + len(delim))
230 if end == -1:
231 return (comments, _ST_RAW, delim)
232 i = end + len(close)
233 continue
234 i = _skip_quoted(line, i, '"')
235 continue
236 if c == "'":
237 i = _skip_quoted(line, i, "'")
238 continue
239 i += 1
240
241 return (comments, _ST_NORMAL, "")
242
243
244def _raw_string_delim(line: str, quote_idx: int) -> str | None:
245 """If ``line[quote_idx]`` opens a C++ raw string, return its ``(``-delimiter.
246
247 A raw string is ``R"delim(`` with an optional encoding prefix (``u8``, ``L``,
248 ``u``, ``U``) on the ``R``. Returns None when this quote is a normal string.
249 """
250 if quote_idx == 0 or line[quote_idx - 1] != "R":
251 return None
252 # Verify the R is a raw-string introducer, not an identifier character.
253 j = quote_idx - 1
254 prefix_start = j
255 while prefix_start > 0 and line[prefix_start - 1] in "uUL8":
256 prefix_start -= 1
257 if prefix_start > 0 and (line[prefix_start - 1].isalnum() or line[prefix_start - 1] == "_"):
258 return None
259 open_paren = line.find("(", quote_idx + 1)
260 if open_paren == -1:
261 return None
262 return line[quote_idx + 1 : open_paren]
263
264
265def _skip_quoted(line: str, i: int, quote: str) -> int:
266 """Return the index just past a ``quote``-delimited literal starting at `i`."""
267 j = i + 1
268 n = len(line)
269 while j < n:
270 if line[j] == "\\":
271 j += 2
272 continue
273 if line[j] == quote:
274 return j + 1
275 j += 1
276 return n # unterminated on this line; treat the rest as consumed
277
278
279def _render(opener: str, content: str, pad: int) -> str:
280 """Build a normalised comment: opener, one space, content, `pad` spaces, ``*/``."""
281 return f"{opener} {content}{' ' * pad}*/"
282
283
284def _body_len(c: _Comment) -> int:
285 """Width of the minimal (one-space) comment ``/**< content */`` for `c`."""
286 return len(c.opener) + 1 + len(c.content) + 1 + 2
287
288
289def _scan_trailing(
290 lines: list[str],
291) -> tuple[list[_Comment | None], list[str], list[int], list[tuple[int, str]]]:
292 """Classify every line's last block comment as trailing, standalone, or neither.
293
294 Returns four parallel results: per line, the trailing comment (``None`` when
295 the line has none), the verbatim text in front of it (code plus
296 clang-format's leading alignment), and the column that comment opens at
297 (``-1`` when there is none); plus a list of ``(index, rendered)`` pairs for
298 full-line standalone comments, which are spacing-normalised but never
299 aligned.
300
301 A line whose last comment is a banner or an empty ``/**/``, or which has
302 code after the ``*/``, yields no trailing comment: neither this pass nor
303 clang-format's ``AlignTrailingComments`` treats those as alignable, so both
304 end an alignment run there.
305 """
306 trailing: list[_Comment | None] = [None] * len(lines)
307 prefix: list[str] = [""] * len(lines)
308 start_col: list[int] = [-1] * len(lines)
309 standalone: list[tuple[int, str]] = []
310
311 state, raw_delim = _ST_NORMAL, ""
312 for i, line in enumerate(lines):
313 comments, state, raw_delim = _scan_line(line, state, raw_delim)
314 if not comments:
315 continue
316 last = comments[-1]
317 if not last.processable or line[last.end :].strip() != "":
318 continue # banner/empty, or code follows -> leave the line untouched
319 before = line[: last.start]
320 if before.strip() == "":
321 standalone.append((i, before + _render(last.opener, last.content, 1)))
322 else:
323 if not before[-1].isspace():
324 before += " " # rule 3: >=1 space before a trailing comment
325 trailing[i] = last
326 prefix[i] = before # preserve code + clang's start-column alignment
327 start_col[i] = len(before)
328 return trailing, prefix, start_col, standalone
329
330
331def _clang_format_off(lines: list[str]) -> set[int]:
332 """Return the indices of lines clang-format has been told to leave alone.
333
334 Everything from a ``clang-format off`` marker up to the matching ``on`` (or
335 end of file) is emitted verbatim, so no alignment rule can be read off it:
336 the columns there are whatever the author typed. Reporting a torn block
337 inside such a region asks for a repair clang-format would never make -- and
338 in one case for a comment whose budget was NEGATIVE, since the author had
339 switched the formatter off precisely to keep an over-long marker on its
340 line.
341 """
342 off: set[int] = set()
343 active = False
344 for i, line in enumerate(lines):
345 marker = _fmt_toggle(line)
346 if marker == "off":
347 active = True
348 elif marker == "on":
349 active = False
350 continue
351 if active:
352 off.add(i)
353 return off
354
355
356def _fmt_toggle(line: str) -> str | None:
357 """Return ``"off"`` / ``"on"`` when `line` is a clang-format toggle comment.
358
359 Both delimiters count, and so does the ``clang-format off: why`` form --
360 clang-format honours a trailing explanation, and this tree uses it, so a
361 matcher demanding the bare spelling would read a live marker as ordinary
362 prose and police a region the formatter never touched.
363 """
364 stripped = line.strip()
365 if stripped.startswith("//"):
366 body = stripped[2:].strip()
367 elif stripped.startswith("/*") and stripped.endswith("*/"):
368 body = stripped[2:-2].strip()
369 else:
370 return None
371 for word in ("off", "on"):
372 if body == f"clang-format {word}" or body.startswith(f"clang-format {word}:"):
373 return word
374 return None
375
376
377class _SplitRun(NamedTuple):
378 """A block of trailing comments that cannot be aligned as a single unit.
379
380 `first`/`last` are the 1-based line numbers bounding the run. `cols` holds
381 the distinct 1-based columns its ``/**<`` openers start at -- more than one
382 means clang-format gave up and split the block. `over` lists the 1-based
383 lines whose minimal comment is too wide to sit at `col`, and `excess` is how
384 many characters the widest of them must lose.
385 """
386
387 first: int
388 last: int
389 cols: tuple[int, ...]
390 col: int
391 over: tuple[int, ...]
392 excess: int
393
394
395def find_split_runs(text: str) -> list[_SplitRun]:
396 """Report every trailing-comment block the column limit tore in two.
397
398 A *run* is a maximal stretch of consecutive lines that each end in a
399 single-line trailing comment. A blank line, a code-only line, a standalone
400 comment, or an inline mid-code comment ends one -- and ends clang-format's
401 alignment sequence too, so a run is exactly the unit clang-format tries to
402 align.
403
404 ``AlignTrailingComments`` aligns a run to its widest code plus one space,
405 but abandons that column and starts a fresh group when it would push the
406 longest comment past ``ColumnLimit``. The result reads as ragged: one
407 struct, two ``/**<`` columns and two ``*/`` columns. This pass cannot
408 repair it -- shortening prose is the author's call -- so it reports.
409
410 A run is reported when its comments open at more than one column, or when
411 the widest one, placed at the run's rightmost column, would reach the limit
412 (which is what splits the closing ``*/`` alignment on the next run of the
413 formatter). Both are the same defect measured before and after
414 clang-format has reacted to it.
415 """
416 lines = text.removesuffix("\n").split("\n")
417 trailing, _prefix, start_col, _standalone = _scan_trailing(lines)
418 for i in _clang_format_off(lines):
419 trailing[i] = None # clang-format aligns nothing here, so neither do we
420
421 found: list[_SplitRun] = []
422 for run in _alignment_runs(lines, trailing):
423 cols = sorted({start_col[k] for k in run})
424 col = cols[-1]
425 over = tuple(k + 1 for k in run if col + _body_len(trailing[k]) > _COLUMN_LIMIT)
426 if len(cols) > 1 or over:
427 widest = max(_body_len(trailing[k]) for k in run)
428 found.append(
429 _SplitRun(
430 first=run[0] + 1,
431 last=run[-1] + 1,
432 cols=tuple(c + 1 for c in cols),
433 col=col + 1,
434 over=over,
435 excess=max(0, col + widest - _COLUMN_LIMIT),
436 )
437 )
438 return found
439
440
441def _alignment_runs(lines: list[str], trailing: list[_Comment | None]) -> list[list[int]]:
442 """Group line indices into the blocks clang-format aligns as one.
443
444 A block is a maximal stretch of consecutive trailing-comment lines at ONE
445 indentation, and it excludes two kinds of line that clang-format keeps out
446 of an alignment sequence regardless of how wide anything is:
447
448 * a line that CLOSES a scope (``}``, ``};``, ``} while (x);``) -- with
449 ``int a; /* a */`` inside a block and ``} /* done */`` after it,
450 clang-format leaves the two comments in unrelated columns;
451 * a preprocessor directive.
452
453 Modelling the unit matters more than it sounds. Judging a whole struct as
454 one block, when clang-format was aligning its members and its anonymous
455 union's closing ``};`` separately all along, reports a tear that is not
456 there and asks for a repair no formatter would ever make. Blocks at
457 different indentation are likewise judged apart: clang-format sometimes
458 aligns across an indent change, so treating them as one unit could only
459 manufacture findings.
460 """
461 runs: list[list[int]] = []
462 cur: list[int] = []
463 indent = -1
464 for i, line in enumerate(lines):
465 stripped = line.lstrip()
466 breaks = (
467 trailing[i] is None
468 or stripped.startswith(_OUT_OF_SEQUENCE)
469 or (cur and len(line) - len(stripped) != indent)
470 )
471 if breaks:
472 if len(cur) >= _MIN_RUN:
473 runs.append(cur)
474 cur = []
475 if trailing[i] is None or stripped.startswith(_OUT_OF_SEQUENCE):
476 continue
477 if not cur:
478 indent = len(line) - len(stripped)
479 cur.append(i)
480 if len(cur) >= _MIN_RUN:
481 runs.append(cur)
482 return runs
483
484
485def fix_text(text: str) -> str:
486 """Return `text` with the comment-format rules applied. Pure; idempotent.
487
488 Only the LAST block comment on a line is ever touched, and only when nothing
489 but whitespace follows it. That covers trailing comments (code before them)
490 and full-line standalone comments (only whitespace before). Inline mid-code
491 comments (``f(/*tag=*/x)``) are left byte-for-byte alone.
492
493 The text in front of a trailing comment (code + clang-format's start-column
494 alignment) is preserved verbatim -- clang-format owns the comment start
495 column. This pass only rewrites each comment from ``/**<`` to ``*/``: one
496 space after the opener, one space before the closer, and, across a run of
497 consecutive trailing comments that clang-format put in the same start
498 column, the closing ``*/`` padded so they line up under the longest (which
499 gets one space). A run is also split where end-alignment would reach the
500 column limit, so the padding never makes clang-format reflow the line.
501 Standalone full-line comments are spacing-normalised but never aligned.
502 """
503 had_trailing_nl = text.endswith("\n")
504 body = text[:-1] if had_trailing_nl else text
505 lines = body.split("\n")
506 out = list(lines)
507
508 trailing, prefix, start_col, standalone = _scan_trailing(lines)
509 for i, rendered in standalone:
510 out[i] = rendered
511
512 # Never cement a block this pass is REPORTING as torn (rule 4). End-padding
513 # is a grouping hint clang-format honours, so a block padded to its two
514 # sub-widths stays in two groups even after the comment that split it has
515 # been shortened -- the author does as the finding asks, runs `just quality::local::format`,
516 # and nothing moves. Rendering a reported block at one space instead lets
517 # the next clang-format round see the minimal form and re-merge it.
518 cemented = {k for run in find_split_runs(text) for k in range(run.first - 1, run.last)}
519
520 # End-align consecutive trailing comments clang put in the same start column.
521 i = 0
522 while i < len(lines):
523 if trailing[i] is None:
524 i += 1
525 continue
526 if i in cemented:
527 c = trailing[i]
528 out[i] = prefix[i] + _render(c.opener, c.content, 1)
529 i += 1
530 continue
531 j = i
532 body_hi = _body_len(trailing[i])
533 while (
534 j + 1 < len(lines) and trailing[j + 1] is not None and start_col[j + 1] == start_col[i]
535 ):
536 nbody = max(body_hi, _body_len(trailing[j + 1]))
537 if start_col[i] + nbody > _COLUMN_LIMIT: # padding would overrun the limit
538 break
539 body_hi = nbody
540 j += 1
541 for k in range(i, j + 1):
542 c = trailing[k]
543 pad = body_hi - _body_len(c) + 1
544 out[k] = prefix[k] + _render(c.opener, c.content, pad)
545 i = j + 1
546
547 result = "\n".join(out)
548 return result + "\n" if had_trailing_nl else result
549
550
551def _is_excluded(path: Path) -> bool:
552 return is_build_output_path(path) or any(frag in str(path) for frag in EXCLUDE_FRAGMENTS)
553
554
555def _is_source(path: Path) -> bool:
556 return path.suffix in SOURCE_SUFFIXES
557
558
559def _rel(path: Path) -> str:
560 if path.is_relative_to(REPO_ROOT):
561 return str(path.relative_to(REPO_ROOT))
562 return str(path)
563
564
565def _enumerate_targets(arg_paths: Iterable[str]) -> list[Path]:
566 args = list(arg_paths)
567 if args:
568 out: list[Path] = []
569 for raw in args:
570 path = Path(raw)
571 if not path.is_absolute():
572 path = REPO_ROOT / path
573 if path.is_dir():
574 out.extend(c for c in path.rglob("*") if c.is_file() and _is_source(c))
575 elif _is_source(path):
576 out.append(path)
577 return [p for p in out if not _is_excluded(p)]
578
579 out = []
580 for root in SCAN_ROOTS:
581 base = REPO_ROOT / root
582 if base.is_dir():
583 out.extend(c for c in base.rglob("*") if c.is_file() and _is_source(c))
584 return [p for p in out if not _is_excluded(p)]
585
586
587def _first_diff_lines(old: str, new: str, limit: int = 4) -> list[tuple[int, str, str]]:
588 """Return up to `limit` (1-based lineno, old, new) tuples that differ."""
589 olines = old.split("\n")
590 nlines = new.split("\n")
591 diffs = []
592 for idx, (o, n) in enumerate(zip(olines, nlines, strict=False), start=1):
593 if o != n:
594 diffs.append((idx, o, n))
595 if len(diffs) >= limit:
596 break
597 return diffs
598
599
600def main(argv: list[str]) -> int:
601 """Report, or with ``--fix`` rewrite, comment blocks whose interior is misaligned.
602
603 Three modes, checked in that order: ``--selftest`` runs the built-in
604 battery and ignores everything else on the line, ``--fix`` rewrites in
605 place and reports how many files changed, and the default reports offenders
606 and fails. Only ``--fix`` writes; the default mode leaves the tree alone.
607
608 A file that cannot be decoded as UTF-8 is skipped silently rather than
609 failed -- comment alignment is not the gate that should adjudicate
610 encoding, and check-encoding already owns that and would report it twice.
611
612 FILE_FLOOR applies to the whole-tree sweep ONLY, and exits 2 below it. An
613 explicit path list is how format_code.sh and the pre-commit hook drive this
614 tool, and it legitimately filters to nothing when a commit touches no C
615 source; an empty SWEEP is a broken enumeration reporting well-formed
616 comments because it read nothing.
617
618 Returns 0 when clean or when ``--fix`` rewrote everything it found, 1 when
619 the default mode found a file needing a rewrite, 2 when the whole-tree
620 sweep enumerated too few files to trust.
621 """
622 args = argv[1:]
623 if "--selftest" in args:
624 return _selftest()
625 do_fix = "--fix" in args
626 paths = [a for a in args if not a.startswith("--")]
627
628 targets = _enumerate_targets(paths)
629 if not paths and len(targets) < FILE_FLOOR:
630 sys.stderr.write(
631 f"check_comment_format.py: FATAL -- only {len(targets)} file(s) in scope, "
632 f"floor is {FILE_FLOOR}.\n"
633 " A collapsed sweep reports well-formed comments because it read nothing.\n"
634 )
635 return 2
636 if not targets:
637 print("check_comment_format.py: no files to scan")
638 return 0
639
640 bad: list[Path] = []
641 split: list[tuple[Path, _SplitRun]] = []
642 fixed = 0
643 for path in sorted(targets):
644 try:
645 original = path.read_text(encoding="utf-8")
646 except (OSError, UnicodeDecodeError):
647 continue
648 updated = fix_text(original)
649 if updated != original:
650 if do_fix:
651 path.write_text(updated, encoding="utf-8")
652 fixed += 1
653 else:
654 bad.append(path)
655 split.extend((path, run) for run in find_split_runs(updated))
656
657 if do_fix:
658 print(f"check_comment_format.py: reformatted {fixed} file(s).")
659 elif bad:
660 _report_rewrites(bad)
661
662 if split:
663 _report_split_runs(split)
664 if bad or split:
665 return 1
666 print(f"check_comment_format.py: {len(targets)} file(s) scanned, comments well-formed.")
667 return 0
668
669
670def _report_rewrites(bad: list[Path]) -> None:
671 """Write the first few lines this pass would rewrite in each file, to stderr."""
672 sys.stderr.write("check_comment_format.py: comment-format finding(s):\n")
673 for path in bad:
674 rel = _rel(path)
675 original = path.read_text(encoding="utf-8")
676 for lineno, old, new in _first_diff_lines(original, fix_text(original)):
677 sys.stderr.write(
678 f" {rel}:{lineno}\n have: {old.rstrip()}\n want: {new.rstrip()}\n"
679 )
680 sys.stderr.write("\nRun: just quality::local::format (or check_comment_format.py --fix)\n")
681
682
683def _report_split_runs(split: list[tuple[Path, _SplitRun]]) -> None:
684 """Write the split-run findings, and how to clear them, to stderr."""
685 sys.stderr.write(
686 f"check_comment_format.py: {len(split)} trailing-comment block(s) too wide to align:\n"
687 )
688 for path, run in split:
689 rel = _rel(path)
690 where = f" {rel}:{run.first}-{run.last}"
691 if len(run.cols) > 1:
692 cols = ", ".join(str(c) for c in run.cols)
693 sys.stderr.write(f"{where}: /**< opens at columns {cols} -- clang-format split it\n")
694 else:
695 sys.stderr.write(f"{where}: at column {run.col} the */ alignment splits\n")
696 if run.over:
697 lines = ", ".join(str(line) for line in run.over[:_MAX_NAMED_LINES])
698 more = ", ..." if len(run.over) > _MAX_NAMED_LINES else ""
699 sys.stderr.write(
700 f" too wide at column {run.col}: line(s) {lines}{more}"
701 f" -- shed {run.excess} char(s)\n"
702 )
703 sys.stderr.write(
704 "\nOne block of trailing comments must align as one: a single /**< column and\n"
705 "a single */ column. clang-format abandons the alignment when the widest code\n"
706 f"plus the longest comment would reach column {_COLUMN_LIMIT}, and this pass\n"
707 "cannot shorten prose for you. Either tighten the comments named above, or\n"
708 "move the long one to its own /** ... */ block above the line it documents.\n"
709 )
710
711
712# ---------------------------------------------------------------------------
713# Built-in test battery (run via --selftest; mirrors the user's examples).
714# ---------------------------------------------------------------------------
715# --- selftest fixtures ------------------------------------------------------
716# The table below is DATA. It lives at module scope because a function wrapping
717# a 90-line literal is still a 90-line function -- the size rule measures the
718# body either way -- and because chopping one cohesive spec into arbitrary
719# sub-tables would cost a reader the ability to scan every rule at once.
720# Each entry is (name, input, expected output).
721
722# Rule 4: align a run of trailing comments to the longest (which gets 1 space).
723_RUN_IN = (
724 "enum {\n"
725 " a = 0x01U, /**< Sync event: VSYNC start. */\n"
726 " b = 0x08U, /**< End of Transmission packet. */\n"
727 "};\n"
728)
729_RUN_OUT = (
730 "enum {\n"
731 " a = 0x01U, /**< Sync event: VSYNC start. */\n"
732 " b = 0x08U, /**< End of Transmission packet. */\n"
733 "};\n"
734)
735
736# Over-padded run gets tightened so the longest has exactly one space.
737_OVER_IN = "enum {\n a = 1, /**< short. */\n b = 2, /**< longer text. */\n};\n"
738_OVER_OUT = "enum {\n a = 1, /**< short. */\n b = 2, /**< longer text. */\n};\n"
739
740# The leading spaces (clang-format's start-column alignment) are preserved
741# verbatim, and comments at different start columns are not cross-aligned --
742# only the comment interior and the */ end column are this pass's business.
743_SEP_IN = " k_a = 1, /**< x.*/\n k_bb = 2, /**< yy. */\n"
744_SEP_OUT = " k_a = 1, /**< x. */\n k_bb = 2, /**< yy. */\n"
745
746# A code-only line breaks the run; the standalone doc line collapses to one
747# space; the trailing comments keep their (clang-owned) leading spaces.
748_BREAK_IN = (
749 "enum {\n"
750 " a = 1U, /**< one. */\n"
751 " b = 2U, /**< two. */\n"
752 " c = (1U << 9),\n"
753 " /**< composite. */\n"
754 "};\n"
755)
756_BREAK_OUT = (
757 "enum {\n"
758 " a = 1U, /**< one. */\n"
759 " b = 2U, /**< two. */\n"
760 " c = (1U << 9),\n"
761 " /**< composite. */\n"
762 "};\n"
763)
764
765# Column-limit guard: end-alignment that would reach the limit splits the run
766# instead, so a short comment is never padded out to a far column (which would
767# make clang-format collapse the leading alignment). `a` is tightened to one
768# space rather than aligned to the long sibling's */.
769_LONG_C = "x" * 84
770_CAP_IN = f" a = 1, /**< short. */\n b = 2, /**< {_LONG_C}. */\n"
771_CAP_OUT = f" a = 1, /**< short. */\n b = 2, /**< {_LONG_C}. */\n"
772
773# A block rule 4 reports is rendered at one space, never padded to its two
774# sub-widths: padding is a grouping hint, and cementing the split would keep
775# clang-format from re-merging the block once the long comment is shortened.
776_CEMENT_IN = " ra8_mount_t* aa; /**< short. */\n uint32_t bb; /**< " + "x" * 78 + ". */\n"
777_CEMENT_OUT = " ra8_mount_t* aa; /**< short. */\n uint32_t bb; /**< " + "x" * 78 + ". */\n"
778
779# Safety: multi-line block comment interior untouched.
780_MULTI = "/**\n * @brief foo.*/bar\n * body\n */\nint x;\n"
781
782_SELFTEST_CASES: tuple[tuple[str, str, str], ...] = (
783 # Rule 1: space after the opener.
784 ("space after /*", "int x; /*hi */\n", "int x; /* hi */\n"),
785 # Rule 2: space before */.
786 ("space before */", "int x; /* hi*/\n", "int x; /* hi */\n"),
787 (
788 "doxy member . */",
789 "bool e; /**< DSISETR.EOTPEN.*/\n",
790 "bool e; /**< DSISETR.EOTPEN. */\n",
791 ),
792 # Rule 3: >=1 space before a trailing comment.
793 ("space before comment", "int x;/* hi */\n", "int x; /* hi */\n"),
794 ("align run to longest", _RUN_IN, _RUN_OUT),
795 ("tighten over-padded run", _OVER_IN, _OVER_OUT),
796 ("leading preserved, columns separate", _SEP_IN, _SEP_OUT),
797 ("code line breaks run", _BREAK_IN, _BREAK_OUT),
798 ("column-limit splits run", _CAP_IN, _CAP_OUT),
799 ("a reported block is left un-padded", _CEMENT_IN, _CEMENT_OUT),
800 # Safety: never touch text inside string literals.
801 ("string with /* */", 'const char* s = "/*x*/";\n', 'const char* s = "/*x*/";\n'),
802 ("string with */", 'puts("a*/b");\n', 'puts("a*/b");\n'),
803 # Safety: never touch // line comments.
804 ("line comment left alone", "int x; // a*/b\n", "int x; // a*/b\n"),
805 ("multiline block untouched", _MULTI, _MULTI),
806 # Safety: banners untouched.
807 ("banner untouched", "/******** section ********/\n", "/******** section ********/\n"),
808 ("empty comment untouched", "x; /**/\n", "x; /**/\n"),
809 # Safety: C++ raw string with */ inside is not a comment.
810 ("raw string untouched", 'auto s = R"(a*/b/*c)";\n', 'auto s = R"(a*/b/*c)";\n'),
811 # Internal double-space (manual sub-column alignment) is preserved.
812 (
813 "internal spacing kept",
814 "x = 1; /**< VBTBPSR Ch 12.2 p 509.*/\n",
815 "x = 1; /**< VBTBPSR Ch 12.2 p 509. */\n",
816 ),
817 # Inline mid-code comments (code follows the */) are left byte-for-byte
818 # alone -- clang-format owns the spacing around them; rewriting them fights
819 # it (e.g. clang then wants a space between */ and the next token).
820 ("inline arg-label spaced", "f(a, /* tag= */ b);\n", "f(a, /* tag= */ b);\n"),
821 ("inline arg-label tight", "f(a, /*tag=*/b);\n", "f(a, /*tag=*/b);\n"),
822 ("inline then trailing", "f(/*a*/x); /*hi*/\n", "f(/*a*/x); /* hi */\n"),
823)
824
825
826# --- split-run detector fixtures -------------------------------------------
827# Must-fire: one struct, two /**< columns, because `b`'s comment is too long to
828# sit at the column the wider declaration above it would impose.
829_SPLIT_TWO_COLS = (
830 f"struct s {{\n ra8_mount_t* aa; /**< short. */\n uint32_t bb; /**< {'x' * 78}. */\n}};\n"
831)
832# Must-fire on the column clause ALONE: two /**< columns, every comment short.
833# This is `_SEP_IN` above, seen from the other side -- fix_text refuses to
834# cross-align the two, and this rule is what says the tree should not contain
835# them in the first place.
836_SPLIT_COLS_ONLY = " k_a = 1, /**< x. */\n k_bb = 2, /**< yy. */\n"
837# Must-fire on the width clause ALONE: one column, but the widest comment
838# reaches the limit, so the */ alignment (not the /**< alignment) is what splits.
839_SPLIT_ONE_COL = f" a = 1, /**< short. */\n b = 2, /**< {'x' * 88}. */\n"
840
841# Must stay quiet: a plain aligned block.
842_QUIET_RUN = "struct s {\n uint32_t a; /**< one. */\n uint32_t b; /**< two. */\n};\n"
843# Must stay quiet: the columns differ, but a code-only line sits between them,
844# which ends clang-format's alignment sequence as well as this pass's run.
845_QUIET_BREAK = (
846 "struct s {\n"
847 " const paint_t* a; /**< one. */\n"
848 " void (*cb)(int x, int y);\n"
849 " uint32_t b; /**< two. */\n"
850 "};\n"
851)
852# Must stay quiet: a blank line ends the run just as firmly.
853_QUIET_BLANK = "struct s {\n const paint_t* a; /**< one. */\n\n uint32_t b; /**< two. */\n};\n"
854# Must stay quiet: a lone trailing comment is a run of one and always aligns.
855_QUIET_SINGLE = f" uint32_t a; /**< {'x' * 60}. */\n"
856# Must stay quiet: clang-format was told to leave the region alone, so its
857# columns are the author's, not an alignment clang-format gave up on.
858_QUIET_FMT_OFF = "// clang-format off\n" + _SPLIT_TWO_COLS + "// clang-format on\n"
859# The `off: why` form is live in this tree and clang-format honours it.
860_QUIET_FMT_OFF_WHY = (
861 "// clang-format off: the marker must stay on the call line.\n" + _SPLIT_TWO_COLS
862)
863# ...but the exemption must END at the `on` marker, or one `off` anywhere in a
864# file would silence the rest of it.
865_SPLIT_AFTER_ON = "// clang-format off\nint x; /* a */\n// clang-format on\n" + _SPLIT_TWO_COLS
866
867# Must stay quiet: clang-format keeps a scope-closing brace out of the alignment
868# sequence, so its comment column says nothing about the members above it.
869# Each of the three fixtures below isolates ONE reason a run ends, so that
870# dropping that one reason is what makes it fire.
871_QUIET_BRACE = " } /* close */\n return; /* ret */\n"
872# Must stay quiet: a preprocessor directive is out of the sequence too. Same
873# indent as its neighbour, so only the directive itself can end the run.
874_QUIET_PREPROC = "int aaaaaa; /* one */\n#endif /* end */\n"
875# Must stay quiet: a change of indentation is judged apart, because
876# clang-format sometimes aligns across one and sometimes does not.
877_QUIET_INDENT = " int a; /* one */\n int bb; /* two */\n"
878
879_SPLIT_CASES: tuple[tuple[str, str, int], ...] = (
880 ("two /**< columns reported", _SPLIT_TWO_COLS, 1),
881 ("two columns alone are enough", _SPLIT_COLS_ONLY, 1),
882 ("one column, */ alignment splits", _SPLIT_ONE_COL, 1),
883 ("aligned block stays quiet", _QUIET_RUN, 0),
884 ("code line ends the run", _QUIET_BREAK, 0),
885 ("blank line ends the run", _QUIET_BLANK, 0),
886 ("single comment stays quiet", _QUIET_SINGLE, 0),
887 ("clang-format off exempts the region", _QUIET_FMT_OFF, 0),
888 ("the off: why spelling exempts too", _QUIET_FMT_OFF_WHY, 0),
889 ("a scope-closing brace is out of the run", _QUIET_BRACE, 0),
890 ("a preprocessor directive is out of the run", _QUIET_PREPROC, 0),
891 ("an indent change is judged apart", _QUIET_INDENT, 0),
892 ("the exemption ends at clang-format on", _SPLIT_AFTER_ON, 1),
893)
894
895
896def _check_split_case(name: str, src: str, want: int) -> int:
897 """Run one detector case; return the number of failures it produced (0 or 1).
898
899 The detector is run on this pass's own output, which is how `main` drives
900 it: a finding that only survives on unformatted input would be noise.
901 """
902 got = len(find_split_runs(fix_text(src)))
903 if got != want:
904 sys.stderr.write(f"[FAIL] {name}: want {want} finding(s), got {got}\n")
905 return 1
906 return 0
907
908
909def _check_case(name: str, src: str, want: str) -> int:
910 """Run one rewrite case; return the number of failures it produced (0 or 1).
911
912 Idempotency is asserted alongside correctness because this pass runs in a
913 formatter loop: a rule that keeps changing its own output would churn every
914 file on every run.
915 """
916 got = fix_text(src)
917 if got != want:
918 sys.stderr.write(f"[FAIL] {name}\n want: {want!r}\n got: {got!r}\n")
919 return 1
920 if fix_text(got) != got:
921 sys.stderr.write(f"[FAIL] {name}: not idempotent\n")
922 return 1
923 return 0
924
925
926def _selftest() -> int:
927 failures = sum(_check_case(*case) for case in _SELFTEST_CASES)
928 failures += sum(_check_split_case(*case) for case in _SPLIT_CASES)
929 total = len(_SELFTEST_CASES) + len(_SPLIT_CASES)
930 if failures:
931 sys.stderr.write(f"check_comment_format.py: selftest FAILED ({failures} case(s)).\n")
932 return 2
933 print(f"check_comment_format.py: selftest passed ({total} cases).")
934 return 0
935
936
937if __name__ == "__main__":
938 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298