ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
gen_doxygen_nav.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"""Generate the Doxygen navigation trees, mirroring the on-disk layout.
5
6Doxygen renders markdown files (docs/*.md and every example README.md) as
7"pages", never as entries in the Files/directory tree -- so the sidebar's
8directory view is code-only, and the human-readable docs + per-app READMEs
9can only be reached through the "pages" tab. This script builds that pages
10tab to MIRROR THE ON-DISK DIRECTORY TREE instead of an invented taxonomy, so
11navigation reads the same way everywhere (browse by directory) and can never
12drift from the repo:
13
14 1. "Documentation" -- the docs/ subtree. Every docs/*.md is a leaf and
15 every docs/<subdir> (adr, SOUP, qualification, reference, ...) is a
16 sub-node, recursively, exactly as they sit on disk. No doc-to-section
17 map: a new page or a new subdir just appears; a removed one just leaves.
18
19 2. "Examples" -- the examples/ subtree. Every app appears under its real
20 hardware-validation tier directory (hw_validated/hil, hw_validated/manual,
21 hw_pending, _unsupported, ra8p1_foundation, ...), each carrying the
22 one-line description from its own README. Moving an app between tiers on
23 disk moves it in the nav with no edit here.
24
25The only hand-maintained data left is a set of PRETTY-TITLE maps (tier and
26docs-subdir display names); every one degrades gracefully -- an unmapped
27directory falls back to a prettified name and still renders, never breaks and
28never lists a removed item.
29
30The generated .dox files are written under docs/generated/ (gitignored) and
31consumed by the main Doxyfile. build_docs.sh runs this before doxygen, so the
32navigation can never drift from the tree.
33"""
34
35from __future__ import annotations
36
37import hashlib
38import html
39import pathlib
40
41ROOT = pathlib.Path(__file__).resolve().parents[2]
42DOCS_DIR = ROOT / "docs"
43EXAMPLES_DIR = ROOT / "examples"
44OUT_DIR = DOCS_DIR / "generated"
45
46# --- Doxygen id manglers -----------------------------------------------------
47# These reproduce the pinned Doxygen 1.16.1's deterministic naming so the
48# generated @subpage / link targets resolve without editing any source file. If
49# Doxygen ever changed the scheme, the warnings gate would flag the unresolved
50# references.
51
52
53def doxy_page_id(relpath: str) -> str:
54 """Doxygen page id for a markdown file.
55
56 Matches the Doxyfile's CASE_SENSE_NAMES=YES scheme: the "md_" prefix, the
57 path with '/' -> '_2' and '_' -> '__', and the original case preserved
58 (e.g. docs/ACRONYMS.md -> md_docs_2ACRONYMS). The '.md' extension is
59 dropped. Under CASE_SENSE_NAMES=NO doxygen would instead escape every
60 uppercase letter, so this mangler is coupled to that Doxyfile setting.
61 """
62 stem = str(pathlib.PurePosixPath(relpath).with_suffix(""))
63 out = ["md_"]
64 for ch in stem:
65 if ch == "/":
66 out.append("_2")
67 elif ch == "_":
68 out.append("__")
69 else:
70 out.append(ch) # case preserved
71 return "".join(out)
72
73
74def doxy_dir_id(relpath: str) -> str:
75 """Doxygen directory-page id: ``dir_`` + md5 of the directory path.
76
77 The path hashed is the STRIP_FROM_PATH-relative one WITH a trailing slash;
78 both details are load-bearing, since doxygen hashes exactly that string and
79 dropping the slash yields a valid-looking id that resolves to nothing.
80
81 Doxygen renders a directory's README.md as that directory page's detailed
82 description -- so this is where an example app's README actually shows,
83 alongside its source-file list (the empty 'README.md File Reference' page
84 is a dead end). Because the Doxyfile sets STRIP_FROM_PATH=., the path fed
85 to the hash is the plain repo-relative one, so the id is machine-independent
86 and matches the same page the Files directory tree links to.
87 """
88 key = relpath.rstrip("/") + "/"
89 # Not security-sensitive: this reproduces doxygen's own directory-page
90 # file-naming hash so links resolve. usedforsecurity=False documents that
91 # and satisfies the lint.
92 digest = hashlib.md5(key.encode("utf-8"), usedforsecurity=False).hexdigest()
93 return "dir_" + digest
94
95
96# --- narrative-docs (directory-mirroring) ------------------------------------
97# The "Documentation" tree mirrors docs/ exactly, so there is NO doc-to-section
98# classification map to rot. The only hand-maintained data is a set of pretty
99# display titles for a few subdirectories; every lookup degrades gracefully --
100# an unmapped subdir simply shows its prettified directory name.
101SUBDIR_TITLES: dict[str, str] = {
102 "adr": "Architecture Decision Records (adr)",
103 "formats": "Binary format specifications (formats)",
104 "SOUP": "SOUP component justifications (SOUP)",
105 "qualification": "Qualification kit (qualification)",
106 "reference": "Datasheet reference (reference)",
107}
108
109# docs/<subdir> holding no narrative markdown (vendored theme, generated .dox,
110# the legacy build tree, and binary/asset dirs). These are excluded from the
111# Doxyfile INPUT too, so linking their READMEs would dangle -- skip them. A new
112# subdir that DOES carry .md is picked up automatically; nothing here hides
113# real narrative content (badges/sbom hold only images/JSON, no *.md).
114DOCS_SKIP_DIRS = {"generated", "doxygen", "doxygen_theme", "badges", "sbom"}
115
116# --- example-tier taxonomy ---------------------------------------------------
117# Pretty titles for the example directory tiers. Unknown tiers fall back to a
118# prettified directory name so a new board tier still renders.
119TIER_TITLES: dict[str, str] = {
120 "ek_ra8d2": "EK-RA8D2 (stock evaluation kit)",
121 "ek_ra8d2/hw_validated": "Hardware-validated",
122 "ek_ra8d2/hw_validated/hil": "Hardware-in-the-loop (HIL)",
123 "ek_ra8d2/hw_validated/c6": "ESP32-C6 companion radio (just hil::c6)",
124 "ek_ra8d2/hil_needs_revalidation": "HIL -- needs re-validation",
125 "ek_ra8d2/hw_validated/manual": "Manual (jumper / button steps)",
126 "ek_ra8d2/hw_pending": "Hardware-pending",
127 "ek_ra8d2/hw_pending/manual": "Manual (jumper / button steps)",
128 "_unsupported": "Needs external hardware",
129 "ra8p1_foundation": "RA8P1 foundation",
130}
131TIER_BRIEFS: dict[str, str] = {
132 "ek_ra8d2": "Apps that run on a stock EK-RA8D2 v1 kit with no added parts.",
133 "ek_ra8d2/hil_needs_revalidation": "Apps moved out of the HIL-passing set: "
134 "each is blocked by bench config, an SD reseat, absent external hardware, "
135 "or is under triage -- see the tier README.",
136 "ek_ra8d2/hw_validated/c6": "Apps that talk to the ESP32-C6 companion radio "
137 "over esp-hosted. Hardware-validated, but on a DIP-switch configuration "
138 "(SW4-4 OFF) that excludes the default HIL pass -- run them with "
139 "`just hil::c6`.",
140 "_unsupported": "Apps that need hardware not on the stock board "
141 "(motor driver, audio CODEC, external radios, ...).",
142 "ra8p1_foundation": "Foundation apps for the RA8P1 variant (RA8D2 + Ethos-U55 NPU).",
143}
144
145
146def prettify(name: str) -> str:
147 """Fallback display title for a directory with no entry in the title maps.
148
149 This is what makes the title maps optional rather than authoritative: a
150 tier or docs subdirectory added on disk renders as Title Case immediately,
151 so forgetting to add a pretty name degrades the label and never drops the
152 node out of the navigation.
153 """
154 return name.replace("_", " ").strip().title()
155
156
157def clean_desc(text: str) -> str:
158 """Reduce a README paragraph to one safe, plain-text line."""
159 text = text.replace("`", "").replace("**", "").replace("*", "")
160 # collapse markdown links [txt](url) -> txt
161 out = []
162 i = 0
163 while i < len(text):
164 if text[i] == "[":
165 close = text.find("]", i)
166 paren = text.find("(", close) if close != -1 else -1
167 if close != -1 and paren == close + 1:
168 end = text.find(")", paren)
169 if end != -1:
170 out.append(text[i + 1 : close])
171 i = end + 1
172 continue
173 out.append(text[i])
174 i += 1
175 text = "".join(out)
176 text = " ".join(text.split())
177 limit = 200
178 if len(text) > limit:
179 cut = text[:limit].rsplit(" ", 1)[0]
180 text = cut + " ..."
181 # HTML-escape, then neutralize Doxygen-active characters.
182 text = html.escape(text, quote=False)
183 return text.replace("@", "&#64;").replace("\\", "&#92;")
184
185
186def read_app_desc(app_dir: pathlib.Path) -> str:
187 """Pull the one-line blurb for an example app out of its own README.
188
189 Takes the first prose paragraph AFTER the H1, which is where these READMEs
190 put their summary. The scan stops at a heading or a table row so an app
191 whose README opens with a status table straight after the title yields an
192 empty description rather than a row of pipes rendered as prose.
193
194 A missing README returns "" rather than raising: an app without one still
195 belongs in the navigation, just without a blurb.
196 """
197 readme = app_dir / "README.md"
198 if not readme.is_file():
199 return ""
200 lines = readme.read_text(encoding="utf-8", errors="replace").splitlines()
201 # skip to after the first H1
202 idx = 0
203 while idx < len(lines) and not lines[idx].startswith("# "):
204 idx += 1
205 idx += 1
206 # skip blanks
207 while idx < len(lines) and not lines[idx].strip():
208 idx += 1
209 para = []
210 while idx < len(lines) and lines[idx].strip():
211 stripped = lines[idx].strip()
212 if stripped.startswith(("#", "|")):
213 break
214 para.append(stripped)
215 idx += 1
216 return clean_desc(" ".join(para))
217
218
219# --- example tree ------------------------------------------------------------
220class TierNode:
221 """One directory level of the examples/ tree, holding its apps and children.
222
223 The tree is built from the app directories found on disk, so a node exists
224 only because some app lives at or below it -- there is no declared tier
225 list to fall out of step with the repository.
226 """
227
228 def __init__(self, path: str) -> None:
229 """Create an empty node for the tier at ``path`` relative to examples/.
230
231 The root is spelled "" rather than "." so that child paths concatenate
232 cleanly and the root's page id can be special-cased.
233 """
234 self.path = path # relative to examples/, "" for root
235 self.apps: list[tuple[str, str, str]] = [] # (name, file_id, desc)
236 self.children: dict[str, TierNode] = {}
237
238 def title(self) -> str:
239 """Display title for this tier, falling back to a prettified leaf name.
240
241 Lookup is on the node's FULL path, so two tiers sharing a leaf name at
242 different depths (``hw_validated/manual`` and ``hw_pending/manual``)
243 can be titled independently.
244 """
245 if self.path in TIER_TITLES:
246 return TIER_TITLES[self.path]
247 return prettify(self.path.split("/")[-1])
248
249 def page_id(self) -> str:
250 """Doxygen ``@page`` id for this tier, unique and identifier-safe.
251
252 Every character that is neither alphanumeric nor an underscore is
253 replaced, so a tier directory containing a dot or a dash cannot emit an
254 id doxygen would silently truncate or reject.
255 """
256 if not self.path:
257 return "ra8_examples"
258 safe = self.path.replace("/", "_")
259 safe = "".join(c if (c.isalnum() or c == "_") else "_" for c in safe)
260 return "ra8_ex_" + safe
261
262
263def build_example_tree() -> tuple[TierNode, int]:
264 """Discover every example app and assemble the tier tree it implies.
265
266 An app is defined as a directory containing ``src/main.c`` and a root
267 ``CMakeLists.txt``, which is what makes
268 the navigation self-maintaining: moving an app between tiers on disk moves
269 it in the sidebar with no edit here, and a deleted app simply stops being
270 found. Intermediate tier nodes are created on demand as each path is
271 walked, so only tiers that actually contain apps appear.
272
273 Returns the root node (whose own ``apps`` list holds any app sitting
274 directly under examples/) and the total app count, which the caller prints
275 in the page brief rather than recomputing.
276 """
277 root = TierNode("")
278 count = 0
279 mains = sorted(EXAMPLES_DIR.glob("**/src/main.c"))
280 for main_c in mains:
281 app_dir = main_c.parent.parent
282 if not (app_dir / "CMakeLists.txt").is_file():
283 continue
284 tier_rel = app_dir.parent.relative_to(EXAMPLES_DIR).as_posix()
285 if tier_rel == ".":
286 tier_rel = ""
287 node = root
288 acc = ""
289 for seg in [s for s in tier_rel.split("/") if s]:
290 acc = f"{acc}/{seg}" if acc else seg
291 node = node.children.setdefault(acc, TierNode(acc))
292 app_rel = app_dir.relative_to(ROOT).as_posix()
293 # Link to the app's DIRECTORY page: doxygen renders the app README as
294 # that page's description and lists its source files there -- the same
295 # page the Files tree reaches. (The README file page itself is empty.)
296 node.apps.append((app_dir.name, doxy_dir_id(app_rel), read_app_desc(app_dir)))
297 count += 1
298 return root, count
299
300
301def emit_subpage_list(out: list[str], entries: list[tuple[str, str]]) -> None:
302 """Emit @subpage links as an HTML bulleted list (one per line).
303
304 entries is a list of (page_id, suffix); suffix is appended after the link
305 (e.g. " (12 apps)") or "" for none.
306 """
307 if not entries:
308 return
309 out.append(" * <ul>")
310 for pid, suffix in entries:
311 out.append(f" * <li>@subpage {pid}{suffix}</li>")
312 out.append(" * </ul>")
313
314
315def emit_tier_page(node: TierNode, out: list[str]) -> None:
316 """Append this tier's ``@page`` block to ``out``, then recurse into children.
317
318 Appends in place rather than returning, so one list accumulates the whole
319 tree in traversal order: a parent's page is emitted before its descendants,
320 which is the order doxygen needs to nest the subpage links correctly.
321
322 Apps are rendered as a table and child tiers as a bulleted subpage list, so
323 a tier holding both reads as sections rather than one mixed list.
324 """
325 brief = TIER_BRIEFS.get(node.path, "")
326 out.append("/**")
327 out.append(f" * @page {node.page_id()} {node.title()}")
328 if brief:
329 out.append(f" * @brief {brief}")
330 out.append(" *")
331 child_nodes = [node.children[k] for k in sorted(node.children)]
332 emit_subpage_list(
333 out,
334 [(c.page_id(), f" ({_count_apps(c)} apps)") for c in child_nodes],
335 )
336 if child_nodes and node.apps:
337 out.append(" *")
338 if node.apps:
339 out.append(f" * @par {len(node.apps)} app(s) in this tier:")
340 out.append(" * <table>")
341 out.append(" * <tr><th>App</th><th>Description</th></tr>")
342 for name, fid, desc in sorted(node.apps):
343 link = f'<a href="{fid}.html"><code>{html.escape(name)}</code></a>'
344 out.append(f" * <tr><td>{link}</td><td>{desc}</td></tr>")
345 out.append(" * </table>")
346 out.append(" */")
347 out.append("")
348 for child in child_nodes:
349 emit_tier_page(child, out)
350
351
352def _count_apps(node: TierNode) -> int:
353 return len(node.apps) + sum(_count_apps(c) for c in node.children.values())
354
355
356def gen_examples() -> str:
357 """Render the complete "Examples" navigation tree as one .dox file body.
358
359 Returns the file text, newline-terminated, ready to be written verbatim --
360 no caller-side assembly, so the generated file has exactly one author.
361 """
362 root, total = build_example_tree()
363 out: list[str] = [
364 "// GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
365 "",
366 "/**",
367 " * @page ra8_examples Examples",
368 f" * @brief All {total} example apps, browsable by the same board and",
369 " * hardware-validation tiers the repository uses on disk.",
370 " *",
371 " * Each app is a self-contained directory under `examples/` with its",
372 " * implementation and boot overrides under `src/`, public interfaces",
373 " * under `inc/`, and build/link configuration at its root. Pick a tier below;",
374 " * every app links to its README. Build any of them with",
375 " * `just apps::build <app>` or run it with `just apps::emulator::run <app>`.",
376 " *",
377 ]
378 child_nodes = [root.children[k] for k in sorted(root.children)]
379 emit_subpage_list(
380 out,
381 [(c.page_id(), f" ({_count_apps(c)} apps)") for c in child_nodes],
382 )
383 out.append(" */")
384 out.append("")
385 for child in child_nodes:
386 emit_tier_page(child, out)
387 return "\n".join(out) + "\n"
388
389
390# --- narrative docs tree -----------------------------------------------------
391def _docs_subid(rel_under_docs: str) -> str:
392 """Stable @page id for a docs/ subdirectory, keyed by its full sub-path.
393
394 Keying on the full path (not just the leaf name) means two subdirs that
395 share a leaf name at different depths cannot collide. The distinct
396 "docsub" prefix keeps it clear of the "ra8_docs" root and of any page id.
397 """
398 safe = "".join(c if (c.isalnum() or c == "_") else "_" for c in rel_under_docs)
399 return "ra8_docsub_" + safe
400
401
402def _walk_docs(dir_path: pathlib.Path, rel_under_docs: str) -> tuple[list[str], str | None, str]:
403 """Emit the @page blocks for a docs/ subtree, mirroring it on disk.
404
405 Returns (page_blocks, subid, title). subid is None when the subtree holds
406 no narrative markdown at all, so the parent omits it -- a directory of only
407 images / JSON never becomes an empty nav node. Sub-directories are listed
408 before files, matching doxygen's own Files-tree convention.
409 """
410 md_files = sorted(
411 (p for p in dir_path.glob("*.md") if p.name.lower() != "readme.md"),
412 key=lambda p: p.name.lower(),
413 )
414 child_dirs = sorted(
415 (p for p in dir_path.iterdir() if p.is_dir() and p.name not in DOCS_SKIP_DIRS),
416 key=lambda p: p.name.lower(),
417 )
418
419 blocks: list[str] = []
420 child_nodes: list[tuple[str, str]] = []
421 for cd in child_dirs:
422 crel = f"{rel_under_docs}/{cd.name}" if rel_under_docs else cd.name
423 cblocks, csubid, ctitle = _walk_docs(cd, crel)
424 if csubid is not None:
425 child_nodes.append((csubid, ctitle))
426 blocks.extend(cblocks)
427
428 leaf_ids = [doxy_page_id(p.relative_to(ROOT).as_posix()) for p in md_files]
429 if not leaf_ids and not child_nodes:
430 return [], None, ""
431
432 subid = _docs_subid(rel_under_docs)
433 title = SUBDIR_TITLES.get(dir_path.name, prettify(dir_path.name))
434 page = ["/**", f" * @page {subid} {title}", " *"]
435 entries = [(cid, "") for cid, _t in child_nodes] + [(pid, "") for pid in leaf_ids]
436 emit_subpage_list(page, entries)
437 page.append(" */")
438 page.append("")
439 # This directory's page first, then its descendants' pages.
440 return page + blocks, subid, title
441
442
443def _handwritten_doc_page_ids() -> list[str]:
444 """Discover hand-written ``@page`` ids in docs/*.dox.
445
446 Without this the hand-written pages would float at the top level of the
447 sidebar rather than nesting under Documentation, since doxygen parents a
448 page only where something ``@subpage``s it.
449
450 Only the docs/ root .dox files are scanned (never docs/generated/, which
451 this script owns). The ids are read straight from the files, so a page that
452 is renamed or deleted is picked up or dropped automatically -- nothing is
453 hardcoded, nothing dangles.
454 """
455 ids: list[str] = []
456 for dox in sorted(DOCS_DIR.glob("*.dox"), key=lambda p: p.name.lower()):
457 for line in dox.read_text(encoding="utf-8", errors="replace").splitlines():
458 stripped = line.lstrip(" *")
459 for tag in ("@page ", "\\page "):
460 if stripped.startswith(tag):
461 pid = stripped[len(tag) :].split()[0]
462 if pid:
463 ids.append(pid)
464 return ids
465
466
467def gen_docs() -> str:
468 """Render the complete "Documentation" navigation tree as one .dox file body.
469
470 Both the generated per-file pages and the ids harvested from hand-written
471 .dox files are listed as children here, so the two kinds of page appear in
472 one tree rather than the hand-written ones floating at the top level.
473
474 Returns the file text, newline-terminated.
475 """
476 # Top-level docs/*.md become leaves of the "Documentation" root; each
477 # docs/<subdir> that carries markdown becomes a sub-node, recursively --
478 # the tree is exactly the on-disk docs/ layout, no classification map.
479 top_md = sorted(
480 (p for p in DOCS_DIR.glob("*.md") if p.name.lower() != "readme.md"),
481 key=lambda p: p.name.lower(),
482 )
483 top_leaf_ids = [doxy_page_id(p.relative_to(ROOT).as_posix()) for p in top_md]
484 top_leaf_ids += _handwritten_doc_page_ids()
485
486 subdirs = sorted(
487 (p for p in DOCS_DIR.iterdir() if p.is_dir() and p.name not in DOCS_SKIP_DIRS),
488 key=lambda p: p.name.lower(),
489 )
490 child_nodes: list[tuple[str, str]] = []
491 sub_blocks: list[str] = []
492 for sd in subdirs:
493 blocks, subid, _title = _walk_docs(sd, sd.name)
494 if subid is not None:
495 child_nodes.append((subid, _title))
496 sub_blocks.extend(blocks)
497
498 out: list[str] = [
499 "// GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
500 "",
501 "/**",
502 " * @page ra8_docs Documentation",
503 " * @brief The hand-written documentation, browsable exactly as it sits",
504 " * under docs/ on disk. (The API reference lives under Topics /",
505 " * Data Structures; the source tree lives under Files.)",
506 " *",
507 ]
508 # Sub-directories first, then the top-level pages -- same order as the
509 # Files directory tree.
510 entries = [(subid, "") for subid, _t in child_nodes]
511 entries += [(pid, "") for pid in top_leaf_ids]
512 emit_subpage_list(out, entries)
513 out.append(" */")
514 out.append("")
515 out.extend(sub_blocks)
516
517 return "\n".join(out) + "\n"
518
519
520# --- directory descriptions (Files tab) --------------------------------------
521# Doxygen resolves a directory reference by RAW STRING SUFFIX rather than by
522# path component, so a top-level name that is also the tail of some other
523# directory's path binds its description to whichever doxygen indexed first.
524# ``libs``, ``port``, and ``scripts`` are live cases: the bare names
525# suffix-match apps/shared_libs, apps/shared_libs/rabook_import, and app-local
526# scripts directories, so a plain ``@dir`` block silently describes
527# whichever directory Doxygen indexed first. The other top-level directories
528# have no such twin and keep their static blocks in docs/doxygen_dirs.dox. Here
529# we emit one path-qualified block per collision.
530COLLIDING_TOP_DIRS: dict[str, str] = {
531 "libs": "Hand-written first-party libraries -- the drivers and substrates "
532 "the rest of the firmware builds on (HAL peripherals and register maps, "
533 "the ra8_core substrate, I/O fabric, security and TrustZone, the e-reader "
534 "stack, storage, networking, and board support). Vendored SOUP lives under "
535 "libs/third_party and is excluded from these docs.",
536 "port": "Adapters between the vendored stacks under libs/third_party and "
537 "this firmware: ThreadX to the RA8 clock tree, NetX Duo to a link layer, "
538 "USBX to ra8_usb, LevelX to an ra8_fs block device, the Mbed TLS feature "
539 "set, NimBLE's HCI transport, esp-hosted, and a hosted POSIX filesystem "
540 "adapter. First-party code held to the full rule set; the stacks it "
541 "adapts are not.",
542 "scripts": "Developer and CI tooling: build, flash, and debug wrappers; "
543 "the HIL bench rig control (Tapo power, J-Link, and RTT); the OpenBao "
544 "secret client and root-of-trust key store; and the check_*.py quality "
545 "gates under scripts/checks that CI and the git hooks enforce.",
546}
547
548
549def gen_dirs() -> str:
550 """Emit ``@dir`` blocks for top-level directories a bare name cannot reach.
551
552 Doxygen matches a directory reference by raw string suffix rather than by
553 path component, so top-level names such as ``libs``, ``port``, and
554 ``scripts`` can also match nested directories. Only colliding names need
555 this treatment; the rest keep their static blocks in docs/doxygen_dirs.dox.
556
557 Returns the file text, newline-terminated.
558 """
559 out = ["// GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.", ""]
560 # Disambiguate with the "<repo-dir>/<name>" prefix rather than an absolute
561 # path: it is unique (nothing nested repeats the repo directory name), and
562 # it avoids a doxygen bug that truncates a directory path at the first
563 # dot-prefixed component (e.g. a ".../.claude/worktrees/.../port" checkout).
564 for name, brief in COLLIDING_TOP_DIRS.items():
565 out.append("/**")
566 out.append(f" * @dir {ROOT.name}/{name}")
567 out.append(f" * @brief {brief}")
568 out.append(" */")
569 out.append("")
570 return "\n".join(out) + "\n"
571
572
573# --- Doxyfile @INCLUDE fragment ----------------------------------------------
574# Directories that hold first-party Python/shell tooling but no firmware. Their
575# .py modules are parsed by doxygen as "namespaces" whose classes then pollute
576# the C firmware's Data Structures list. We keep the files browsable under Files
577# but drop their module symbols from the API via a generated EXCLUDE_SYMBOLS.
578PY_TOOL_DIRS = ["scripts", "tools", "apps", "libs", "examples"]
579CONFIG_SKIP = {"third_party", "build", "__pycache__", "_deps", "doxygen"}
580
581
582def python_module_symbols() -> list[str]:
583 """Collect the module STEMS of every first-party Python file, deduplicated.
584
585 Doxygen parses a .py file as a namespace named after its stem, and those
586 namespaces' classes then appear in the C firmware's Data Structures list.
587 Feeding these to EXCLUDE_SYMBOLS drops the symbols while leaving the files
588 themselves browsable under Files.
589
590 Stems, not paths: EXCLUDE_SYMBOLS matches symbol names, so two like-named
591 modules in different directories collapse to one entry -- which is correct
592 here, since both would produce the same namespace name.
593 """
594 mods: set[str] = set()
595 for top in PY_TOOL_DIRS:
596 base = ROOT / top
597 if not base.is_dir():
598 continue
599 for py in base.rglob("*.py"):
600 if any(part in CONFIG_SKIP for part in py.parts):
601 continue
602 mods.add(py.stem)
603 return sorted(mods)
604
605
606def gen_config() -> str:
607 """Render the EXCLUDE_SYMBOLS fragment the Doxyfile ``@INCLUDE``s.
608
609 Emitted as a Doxyfile fragment rather than edited into the Doxyfile so the
610 list regenerates with the tree; a new tooling module is excluded the next
611 time the docs build runs, with nothing to remember to update.
612
613 Returns the file text, newline-terminated.
614 """
615 mods = python_module_symbols()
616 lines = [
617 "# GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
618 "# Drop first-party Python tooling module symbols from the API so the",
619 "# C firmware's Data Structures / Topics lists stay clean. The .py files",
620 "# themselves remain listed and browsable under Files.",
621 "EXCLUDE_SYMBOLS = " + " \\\n ".join(mods),
622 ]
623 return "\n".join(lines) + "\n"
624
625
626def main() -> int:
627 """Write all four generated navigation files into docs/generated/.
628
629 build_docs.sh runs this immediately before doxygen, so the four files are
630 always regenerated from the current tree rather than read from a previous
631 build -- which is what makes stale navigation structurally impossible
632 rather than merely unlikely.
633
634 Output is written as ASCII, so a non-ASCII character reaching a README
635 blurb fails here loudly instead of producing mojibake in the rendered
636 site; the repo is ASCII-only by policy and this is where that is enforced
637 for generated navigation.
638
639 Returns 0; failures surface as exceptions rather than a status code, since
640 every one of them (unwritable output, non-ASCII input) is a build fault
641 with no partial-success reading.
642 """
643 OUT_DIR.mkdir(parents=True, exist_ok=True)
644 (OUT_DIR / "nav_examples.dox").write_text(gen_examples(), encoding="ascii")
645 (OUT_DIR / "nav_docs.dox").write_text(gen_docs(), encoding="ascii")
646 (OUT_DIR / "nav_dirs.dox").write_text(gen_dirs(), encoding="ascii")
647 (OUT_DIR / "nav_config.doxy").write_text(gen_config(), encoding="ascii")
648 print(
649 f"gen_doxygen_nav: wrote {OUT_DIR}/nav_examples.dox, nav_docs.dox, "
650 "nav_dirs.dox, nav_config.doxy"
651 )
652 return 0
653
654
655if __name__ == "__main__":
656 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298