#!/usr/bin/env python3 """scripts/gen-headers-md.py Auto-generate `doc/api/headers.md` from the Doxygen XML index. For each public header under `code/include/`, the script extracts: * the header's `@file` brief description (if present), else falls back to the first leading `//` comment block in the source file; * the names of all public classes, structs, free functions, enums and typedefs declared at file scope (one-line list). Headers are grouped by their directory: * `code/include/CGAL/` → "CGAL public API" * `code/include/CGAL/.../` → nested CGAL subgroups (internal_np, …) * `code/include/*.hpp` → "Core (conformallab namespace)" The file is auto-generated; do NOT hand-edit. Regenerate via: bash scripts/regen-docs.sh # convenience wrapper # or: doxygen Doxyfile && python3 scripts/gen-headers-md.py Prerequisite: `GENERATE_XML = YES` in the Doxyfile (already set). """ from __future__ import annotations import os, sys, glob, xml.etree.ElementTree as ET from collections import defaultdict REPO_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) XML_DIR = os.path.join(REPO_ROOT, "doc", "doxygen", "xml") OUT_PATH = os.path.join(REPO_ROOT, "doc", "api", "headers.md") PUBLIC_ABS = os.path.join(REPO_ROOT, "code", "include") + os.sep PUBLIC_REL = "code/include/" def is_public(path: str) -> bool: return path.startswith(PUBLIC_ABS) or path.startswith(PUBLIC_REL) def relpath(path: str) -> str: if path.startswith(PUBLIC_ABS): return path[len(PUBLIC_ABS):] if path.startswith(PUBLIC_REL): return path[len(PUBLIC_REL):] return path def text_of(elem: ET.Element | None) -> str: """Concatenate all text under elem, trimming whitespace.""" if elem is None: return "" return " ".join("".join(elem.itertext()).split()).strip() def first_sentence(text: str, max_chars: int = 240) -> str: """Return the first sentence (or first `max_chars` chars) of text. Heuristic: split at the first ". " (period + space) that follows at least 30 characters, else hard-truncate.""" text = text.strip() if not text: return "" # Strip Doxygen tag-class artefacts that bleed into briefs text = text.replace("CGAL::Discrete_conformal_map", "") # spurious self-reference for cut in [". ", ".\n", "; "]: i = text.find(cut, 30) if 0 < i < max_chars: return text[: i + 1].strip() if len(text) > max_chars: return text[: max_chars - 1].rstrip() + "…" return text def first_leading_comment(path: str) -> str: """Fallback: extract the first contiguous //-comment block at top of file.""" try: with open(path, encoding="utf-8") as f: lines = [] started = False for ln in f: s = ln.strip() if s.startswith("//"): lines.append(s.lstrip("/").strip()) started = True elif started: break elif not s or s.startswith("#pragma"): continue else: break return " ".join(l for l in lines if l) except OSError: return "" def scan_xml() -> dict[str, dict]: """Walk all compounddef kind='file' XMLs and return a dict keyed by absolute path → {'brief': str, 'symbols': [list]}. """ out: dict[str, dict] = {} for xml in sorted(glob.glob(os.path.join(XML_DIR, "*.xml"))): bn = os.path.basename(xml) if bn in {"index.xml", "Doxyfile.xml", "indexpage.xml"}: continue try: tree = ET.parse(xml) except ET.ParseError: continue for cd in tree.iter("compounddef"): if cd.attrib.get("kind") != "file": continue loc = cd.find("location") if loc is None: continue path = loc.attrib.get("file", "") if not is_public(path): continue # Normalise to absolute for downstream file IO if path.startswith(PUBLIC_REL): path = os.path.join(REPO_ROOT, path) ext = os.path.splitext(path)[1] if ext not in {".h", ".hpp"}: continue brief = text_of(cd.find("briefdescription")) if not brief: brief = text_of(cd.find("detaileddescription")) if not brief: brief = first_leading_comment(path) brief = first_sentence(brief) symbols: list[str] = [] # Free symbols defined directly in the file for sect in cd.iter("sectiondef"): for m in sect.iter("memberdef"): mk = m.attrib.get("kind", "") if mk not in {"function", "typedef", "enum", "variable"}: continue if m.attrib.get("prot", "public") != "public": continue name = m.findtext("name", "?") qual = m.findtext("qualifiedname", name) if "::detail" in qual or "::cp_detail" in qual or \ "::id_detail" in qual or "::detail_xml" in qual: continue if mk == "function": symbols.append(f"`{name}()`") elif mk == "typedef": symbols.append(f"`{name}`") elif mk == "enum": symbols.append(f"`enum {name}`") else: symbols.append(f"`{name}`") # Classes/structs declared in the file (skip template # specialisations whose name contains angle brackets — those # appear as duplicates of the primary template). for cref in cd.iter("innerclass"): cname = cref.text or "" if "detail" in cname or "<" in cname: continue short = cname.rsplit("::", 1)[-1] symbols.append(f"`{short}`") # De-duplicate, preserve order seen = set() uniq = [] for s in symbols: if s in seen: continue seen.add(s); uniq.append(s) out[path] = {"brief": brief, "symbols": uniq} return out def group_key(path: str) -> tuple[str, int]: rel = relpath(path) parts = rel.split(os.sep) if parts[0] == "CGAL": if len(parts) == 2: return ("CGAL public API (``)", 1) return (f"CGAL internals (``)", 2) return ("Core (`conformallab` namespace, `<...>`)", 3) def render(headers: dict[str, dict]) -> str: groups: dict[tuple[str, int], list[tuple[str, dict]]] = defaultdict(list) for path, info in headers.items(): groups[group_key(path)].append((path, info)) lines: list[str] = [] lines.append("") lines.append("") lines.append("") lines.append("# Public Headers (`code/include/`)") lines.append("") lines.append("All algorithms are header-only. Include the headers you need") lines.append("directly — there is no compiled library to link against (only") lines.append("GTest for tests and CGAL/Eigen for the CGAL-dependent headers).") lines.append("") lines.append("This page is **regenerated** from Doxygen XML on every push to") lines.append("`main` that touches a public header (see") lines.append("`.gitea/workflows/doxygen-pages.yml` and `scripts/gen-headers-md.py`).") lines.append("To improve a description, edit the `\\file` brief at the top of") lines.append("the corresponding header, then re-run `bash scripts/regen-docs.sh`.") lines.append("") for key in sorted(groups.keys(), key=lambda k: (k[1], k[0])): title, _ = key lines.append(f"## {title}") lines.append("") lines.append("| Header | Brief | Public symbols |") lines.append("|---|---|---|") for path, info in sorted(groups[key], key=lambda x: x[0]): rel = relpath(path).replace(os.sep, "/") brief = info["brief"] or "_(undocumented — add a `\\file` brief at the top of the header)_" # Defensive: collapse pipe/newline chars that would break the table brief = brief.replace("|", "\\|").replace("\n", " ").strip() syms = ", ".join(info["symbols"][:10]) or "_(no public symbols)_" if len(info["symbols"]) > 10: syms += f", … (+{len(info['symbols']) - 10} more)" lines.append(f"| `{rel}` | {brief} | {syms} |") lines.append("") return "\n".join(lines) + "\n" def main() -> int: if not os.path.isdir(XML_DIR): print(f"ERROR: {XML_DIR} missing — run `doxygen Doxyfile` first", file=sys.stderr) return 2 headers = scan_xml() if not headers: print("ERROR: no public headers found in XML output", file=sys.stderr) return 2 text = render(headers) with open(OUT_PATH, "w", encoding="utf-8") as f: f.write(text) print(f"Wrote {OUT_PATH} ({len(headers)} headers, {sum(len(h['symbols']) for h in headers.values())} symbols)") return 0 if __name__ == "__main__": sys.exit(main())