ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
doxy_members.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The member gate: every enum value, struct/union member and macro is documented.
4
5CLAUDE.md ("Doxygen Documentation Requirements") demands documentation on each
6one -- an inline ``/**< ... */`` or a preceding ``/** ... */`` block on every
7aggregate member, and a ``@brief``/``@def`` block on every macro.
8
9Separate from the function gate because it asks a different question of a
10different view of the source (see :mod:`doxy_lex`) over a wider scope (see
11:mod:`doxy_scope`), and because the two were enforced at different times: the
12function gate has always been strict, the member gate became strict in #246.
13"""
14
15from __future__ import annotations
16
17import re
18from pathlib import Path
19
20from doxy_lex import _body_depth, _first_code_offset, _match_brace, blank_noncode
21from doxy_scope import repo_root
22
23_AGG_KW_RE = re.compile(r"\b(enum|struct|union)\b")
24_AGG_TERMINATORS = frozenset(";=,}")
25
26
27def find_aggregate_bodies(code: str) -> list[tuple[str, int, int]]: # noqa: PLR0912 # char scanner; a helper per branch hurts clarity
28 """Locate enum/struct/union *definitions* (those with a ``{ ... }`` body).
29
30 ``code`` must be the blanked code-only view so keywords/braces inside
31 comments and strings are already gone. Returns a list of
32 ``(kind, open_idx, close_idx)`` for every definition, including nested ones
33 (each nested aggregate is reported on its own; the parent scan skips the
34 nested body via brace depth).
35 """
36 results = []
37 n = len(code)
38 for m in _AGG_KW_RE.finditer(code):
39 kind = m.group(1)
40 p = m.end()
41 depth = 0
42 open_idx = None
43 # Between the keyword and a definition's `{` C allows only an
44 # optional tag and attributes. A `*` or a completed parameter list
45 # means the keyword is an elaborated type specifier on a
46 # declarator instead -- `struct os_mbuf* f(void) { ... }` is a
47 # function returning a pointer, not a struct definition, and
48 # reading its body as a member list demands doc comments on the
49 # statements inside it.
50 declarator = False
51 while p < n:
52 ch = code[p]
53 if ch in "([":
54 depth += 1
55 elif ch in ")]":
56 if depth == 0:
57 break
58 depth -= 1
59 if depth == 0:
60 declarator = True
61 elif depth == 0:
62 if ch == "*":
63 declarator = True
64 elif ch == "{":
65 if not declarator:
66 open_idx = p
67 break
68 elif ch in _AGG_TERMINATORS:
69 break
70 p += 1
71 if open_idx is None:
72 continue
73 close_idx = _match_brace(code, open_idx)
74 if close_idx is None:
75 continue
76 results.append((kind, open_idx, close_idx))
77 return results
78
79
80def member_name(kind: str, seg_code: str) -> str:
81 """Best-effort declarator name for a member segment (for the report only)."""
82 s = seg_code.strip()
83 if not s:
84 return "(anon)"
85 if kind == "enum":
86 m = re.match(r"([A-Za-z_]\w*)", s)
87 return m.group(1) if m else "(anon)"
88 if "}" in s: # named field after a nested aggregate: "union { ... } name"
89 s = s[s.rindex("}") + 1 :]
90 s = re.sub(r":\s*\d+", "", s) # drop bitfield width
91 s = re.sub(r"\‍[[^\‍]]*\‍]", "", s) # drop array subscripts
92 fp = re.search(r"\‍(\s*\*\s*([A-Za-z_]\w*)", s) # function-pointer member
93 if fp:
94 return fp.group(1)
95 ids = re.findall(r"[A-Za-z_]\w*", s)
96 return ids[-1] if ids else "(anon)"
97
98
99def audit_aggregate( # noqa: PLR0913 # body slice
100 kind: str,
101 code: str,
102 comments: list[tuple[int, int, str | None]],
103 open_idx: int,
104 close_idx: int,
105 rel: str,
106) -> list[tuple[str, int, str, str, str]]:
107 """Yield offender rows for undocumented members of one aggregate body.
108
109 A member is documented when it carries either a preceding doc block
110 (``/** ... */`` etc.) in the gap before its code, or an inline doc comment
111 (``/**< ... */`` etc.) trailing it at the same brace depth. Both forms
112 satisfy CLAUDE.md. Inline docs nested one level deeper belong to the inner
113 aggregate (audited on its own pass), not to the enclosing named field.
114 """
115 delim = "," if kind == "enum" else ";"
116 body_start, body_end = open_idx, close_idx
117
118 delims = []
119 depth = 0
120 i = body_start + 1
121 while i < body_end:
122 ch = code[i]
123 if ch in "([{":
124 depth += 1
125 elif ch in ")]}":
126 depth -= 1
127 elif depth == 0 and ch == delim:
128 delims.append(i)
129 i += 1
130
131 lows = [body_start, *delims]
132 highs = [*delims, body_end]
133 code_starts = [_first_code_offset(code, lo, hi) for lo, hi in zip(lows, highs, strict=False)]
134
135 rows = []
136 for idx, (lo, cs) in enumerate(zip(lows, code_starts, strict=False)):
137 if cs is None:
138 continue # whitespace-only trailer (e.g. after the last comma)
139 nxt = next((c for c in code_starts[idx + 1 :] if c is not None), body_end)
140 pre_ok = any(st == "pre" and lo < s < cs for s, _e, st in comments)
141 inline_ok = any(
142 st == "inline" and cs < s < nxt and _body_depth(code, body_start, s) == 0
143 for s, _e, st in comments
144 )
145 if pre_ok or inline_ok:
146 continue
147 line_no = code.count("\n", 0, cs) + 1
148 name = member_name(kind, code[cs : highs[idx]])
149 label = "enum-value" if kind == "enum" else "member"
150 rows.append((rel, line_no, label, name, "undocumented"))
151 return rows
152
153
154_DEFINE_RE = re.compile(r"^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)", re.MULTILINE)
155
156
157def _preceding_doc_run(
158 comments: list[tuple[int, int, str | None]], code: str, upto: int
159) -> tuple[list[tuple[int, int]], bool]:
160 """Concatenated raw text + has-pre flag for the doc run just above ``upto``.
161
162 Walks the contiguous run of comments immediately preceding ``upto`` (only
163 whitespace allowed in the gaps) so both a single ``/** ... */`` block and a
164 stack of ``///`` lines are captured. Returns ``(text_spans, has_pre)`` where
165 ``text_spans`` is a list of ``(start, end)`` for the run's comments.
166 """
167 spans = []
168 has_pre = False
169 boundary = upto
170 for start, end, style in reversed(comments):
171 if end > boundary:
172 continue
173 if code[end:boundary].strip(): # code between comment and boundary
174 break
175 spans.append((start, end))
176 has_pre = has_pre or style == "pre"
177 boundary = start
178 return spans, has_pre
179
180
181_STANDARD_FEATURE_MACROS = frozenset(
182 {
183 "_GNU_SOURCE",
184 "_POSIX_C_SOURCE",
185 "_DARWIN_C_SOURCE",
186 "_DEFAULT_SOURCE",
187 "_XOPEN_SOURCE",
188 "_BSD_SOURCE",
189 "_ISOC11_SOURCE",
190 "_ISOC99_SOURCE",
191 }
192)
193
194
195def audit_macros(
196 raw: str, code: str, comments: list[tuple[int, int, str | None]], rel: str
197) -> list[tuple[str, int, str, str, str]]:
198 """Yield offender rows for macros lacking a ``@brief``/``@def`` doc block."""
199 rows = []
200 for m in _DEFINE_RE.finditer(code):
201 name = m.group(1)
202 if name in _STANDARD_FEATURE_MACROS:
203 continue
204 def_line = m.start()
205 spans, has_pre = _preceding_doc_run(comments, code, def_line)
206 line_no = code.count("\n", 0, def_line) + 1
207 if not has_pre:
208 rows.append((rel, line_no, "macro", name, "undocumented"))
209 continue
210 doc_text = "".join(raw[s:e] for s, e in spans)
211 if "@brief" not in doc_text and "@def" not in doc_text:
212 rows.append((rel, line_no, "macro", name, "missing-brief-or-def"))
213 return rows
214
215
216def audit_members_file(path: Path) -> list[tuple[str, int, str, str, str]]:
217 """Return member/enum/macro offender rows for one first-party file."""
218 try:
219 raw = path.read_text(encoding="utf-8", errors="replace")
220 except OSError:
221 return []
222 code, comments = blank_noncode(raw)
223 try:
224 rel = str(path.relative_to(repo_root()))
225 except ValueError: # explicit path outside the repo (e.g. a test fixture)
226 rel = str(path)
227 rows = []
228 for kind, open_idx, close_idx in find_aggregate_bodies(code):
229 rows.extend(audit_aggregate(kind, code, comments, open_idx, close_idx, rel))
230 rows.extend(audit_macros(raw, code, comments, rel))
231 rows.sort(key=lambda r: r[1])
232 return rows