Replaces the hand-maintained `doc/api/headers.md` with a generated one
sourced from each header's `\file` brief and the public symbols
extracted by Doxygen into XML. The CI workflow regenerates it on every
push to main that touches the public headers.
New files
─────────
* scripts/gen-headers-md.py — parses doc/doxygen/xml/*.xml, groups
headers by directory (CGAL public / CGAL internals / Core), and
writes a markdown table with header path, first-sentence brief, and
the public symbols declared at file scope. Skips `detail::`
namespaces and template-specialisation duplicates.
* scripts/regen-docs.sh — convenience wrapper:
doxygen → gen-headers-md.py → coverage report.
Workflow changes
────────────────
.gitea/workflows/doxygen-pages.yml now:
1. Runs `bash scripts/doxygen-coverage.sh` as an informational step
(no fail threshold yet — the script supports `--threshold N` for
when we're ready).
2. Re-runs `python3 scripts/gen-headers-md.py` and warns if the
file drifted from what's in main (operator should run
`regen-docs.sh` locally before pushing).
Doxyfile hygiene
────────────────
`HTML_TIMESTAMP` was removed in Doxygen 1.10 → replaced with the new
`TIMESTAMP = NO` to silence the obsolete-tag warning.
Effect on the reviewer-facing landing pages
───────────────────────────────────────────
Every improvement to a `\file` brief at the top of a public header now
flows automatically into both:
* the Doxygen HTML at https://tmoussa.codeberg.page/ConformalLabpp/
* the markdown landing at doc/api/headers.md (rendered by Codeberg
in the repo view)
…so writers have a single source of truth (the C++ source) and
readers see the same words in both surfaces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
243 lines
9.4 KiB
Python
Executable File
243 lines
9.4 KiB
Python
Executable File
#!/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 (`<CGAL/...>`)", 1)
|
|
return (f"CGAL internals (`<CGAL/{parts[1]}/...>`)", 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("<!-- AUTO-GENERATED by scripts/gen-headers-md.py — do not edit by hand. -->")
|
|
lines.append("<!-- Source of truth: the `\\file` / leading-comment briefs in code/include/. -->")
|
|
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())
|