Files
essim/doc/gen_api_md.py
François 66460262af Auto-generated API doc: doxygen → custom Python emitter → doc/api/.
`cmake --build build --target doc` runs Doxygen to produce XML, then
`doc/gen_api_md.py` (~330 lines, stdlib-only) emits a Markdown tree
under `doc/api/` that gitea renders directly in its file browser.

- 24 class/struct pages + 51 source-file pages + indices, with source
  links of the form `../../../../src/...#L42` that gitea turns into
  clickable line-anchored links.
- Doxyfile.in templated by CMake (XML-only output to build/doc/xml/).
- Pure Python emitter, zero external deps — no doxybook2 (not packaged
  on Arch) and no moxygen (avoids Node).
- Target gracefully disabled if Doxygen or Python 3 is missing at
  configure time; regular build target unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 08:13:15 +02:00

534 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Convert the Doxygen XML output to a Markdown tree readable directly by
gitea's web interface.
Usage:
python3 doc/gen_api_md.py <xml_dir> <out_dir>
Layout produced (rooted at <out_dir>):
index.md — landing page (classes + files TOC)
classes/
index.md — table of classes
<Name>.md — one per class/struct (members, briefs, source links)
files/
index.md — table of source files
<name>.md — one per file (compounds defined + free funcs)
Source links use relative paths so gitea turns them into clickable links
straight to the right line of the source file.
"""
from __future__ import annotations
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
# ----------------------------------------------------------------------
# Inline rendering helpers
# ----------------------------------------------------------------------
CLASS_REFID_RE = re.compile(r"^(class|struct)([A-Za-z_].*)$")
def ref_to_md(refid: str, label: str) -> str:
"""Turn a Doxygen refid into a Markdown link target if it points to a
known class/struct, otherwise just return the label.
Links are emitted relative to the `doc/api/` root (so they resolve
identically from `classes/` and `files/` pages thanks to the
intervening `..`)."""
if not refid:
return label
m = CLASS_REFID_RE.match(refid)
if m and "_1" not in refid:
return f"[{label}](../classes/{m.group(2)}.md)"
return f"`{label}`" if label else refid
def plain_text(elem: ET.Element | None) -> str:
"""Render an element as plain text — no Markdown, no links. Used inside
code spans where Markdown syntax wouldn't be rendered anyway."""
if elem is None:
return ""
parts: list[str] = []
if elem.text:
parts.append(elem.text)
for c in elem:
parts.append(plain_text(c))
if c.tail:
parts.append(c.tail)
return "".join(parts)
def inline_md(elem: ET.Element | None) -> str:
"""Render a Doxygen inline-text element as Markdown."""
if elem is None:
return ""
out: list[str] = []
if elem.text:
out.append(elem.text)
for c in elem:
tag = c.tag
if tag == "ref":
out.append(ref_to_md(c.attrib.get("refid", ""), (c.text or "").strip()))
elif tag == "computeroutput":
out.append("`" + inline_md(c).strip() + "`")
elif tag == "emphasis":
out.append("*" + inline_md(c) + "*")
elif tag == "bold":
out.append("**" + inline_md(c) + "**")
elif tag == "linebreak":
out.append(" \n")
elif tag == "ulink":
href = c.attrib.get("url", "")
out.append(f"[{(c.text or href).strip()}]({href})")
elif tag == "verbatim":
out.append("\n\n```\n" + (c.text or "") + "\n```\n\n")
else:
# Unknown / generic container: recurse, preserves nested text.
out.append(inline_md(c))
if c.tail:
out.append(c.tail)
return "".join(out)
def render_para(para: ET.Element) -> tuple[str, list[tuple[str, str]],
list[str], list[tuple[str, str]]]:
"""Render one <para> element. Returns (prose, params, returns, throws).
Parameter / return / throws annotations from the para are pulled out
so the caller can lay them out separately.
"""
params: list[tuple[str, str]] = []
returns: list[str] = []
throws: list[tuple[str, str]] = []
out: list[str] = []
if para.text:
out.append(para.text)
for c in para:
tag = c.tag
if tag == "parameterlist":
kind = c.attrib.get("kind", "")
for pi in c.findall("parameteritem"):
name_el = pi.find("parameternamelist/parametername")
name = (name_el.text or "").strip() if name_el is not None else ""
desc = inline_md(pi.find("parameterdescription")).strip()
if kind == "param":
params.append((name, desc))
elif kind == "exception":
throws.append((name, desc))
elif tag == "simplesect":
kind = c.attrib.get("kind", "")
if kind == "return":
returns.append(inline_md(c).strip())
elif kind in ("note", "warning"):
out.append(f"\n\n> **{kind.capitalize()}:** {inline_md(c).strip()}\n\n")
elif tag == "programlisting":
out.append("\n\n```cpp\n")
for codeline in c.findall("codeline"):
out.append("".join(codeline.itertext()) + "\n")
out.append("```\n\n")
elif tag == "itemizedlist":
out.append("\n\n")
for li in c.findall("listitem"):
out.append("- " + "\n ".join(
render_para(p)[0].strip() for p in li.findall("para")
) + "\n")
out.append("\n")
elif tag in ("ref", "computeroutput", "emphasis", "bold",
"linebreak", "ulink", "verbatim"):
# Reuse inline_md for one-element rendering.
wrap = ET.Element("_w")
wrap.append(c)
out.append(inline_md(wrap).rstrip())
else:
out.append(inline_md(c))
if c.tail:
out.append(c.tail)
return "".join(out).strip(), params, returns, throws
def render_description(elem: ET.Element | None) -> tuple[str, list[tuple[str, str]],
list[str], list[tuple[str, str]]]:
"""Render <briefdescription> or <detaileddescription> as Markdown +
extract @param / @return / @throws lists."""
if elem is None:
return "", [], [], []
paras: list[str] = []
params: list[tuple[str, str]] = []
returns: list[str] = []
throws: list[tuple[str, str]] = []
for p in elem.findall("para"):
prose, ps, rs, ts = render_para(p)
if prose:
paras.append(prose)
params.extend(ps)
returns.extend(rs)
throws.extend(ts)
return "\n\n".join(paras), params, returns, throws
# ----------------------------------------------------------------------
# Source link helpers
# ----------------------------------------------------------------------
def src_link(loc: ET.Element | None, depth_to_root: int = 2) -> str:
"""Build a relative Markdown link to the source location.
depth_to_root = number of `..` segments to escape from the .md page
back to the repo root. Class/file pages live at `doc/api/<sub>/<x>.md`,
so depth 2 (to `doc/api/`) + an extra 2 to get back to the repo root.
"""
if loc is None:
return ""
file_ = loc.attrib.get("file", "")
line = loc.attrib.get("line", "")
if not file_:
return ""
prefix = "../" * (depth_to_root + 2) # ../../../../ from doc/api/<sub>/<x>.md
target = f"{prefix}{file_}"
if line:
target += f"#L{line}"
label = Path(file_).name + (f":{line}" if line else "")
return f"[{label}]({target})"
# ----------------------------------------------------------------------
# Compound rendering
# ----------------------------------------------------------------------
def member_signature(m: ET.Element) -> str:
"""Reconstruct a member's C++ declaration as it should appear in the doc.
Uses plain text for the type (no Markdown links) since the signature
sits inside a code span where Markdown isn't rendered anyway. Whitespace
is normalised so that `Signals *` and `Signals*` look the same.
"""
kind = m.attrib.get("kind", "")
type_ = plain_text(m.find("type")).strip()
type_ = re.sub(r"\s+", " ", type_)
name = (m.findtext("name") or "").strip()
if kind == "function":
argstr = (m.findtext("argsstring") or "").strip()
return f"{type_} {name}{argstr}".strip() if type_ else f"{name}{argstr}"
if kind == "variable":
return f"{type_} {name}".strip()
if kind == "enum":
return f"enum {name}"
if kind == "typedef":
defn = (m.findtext("definition") or "").strip()
return defn or f"typedef {type_} {name}"
return name
def emit_class(xml_dir: Path, out_dir: Path, refid: str) -> None:
tree = ET.parse(xml_dir / f"{refid}.xml")
root = tree.find("compounddef")
if root is None:
return
kind = root.attrib.get("kind", "class")
name = (root.findtext("compoundname") or "").strip()
out_path = out_dir / "classes" / f"{refid[5 if kind == 'class' else 6:]}.md"
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
header_bits = [f"`{kind} {name}`"]
bases = []
for b in root.findall("basecompoundref"):
label = (b.text or "").strip()
bases.append(ref_to_md(b.attrib.get("refid", ""), label))
if bases:
header_bits.append("— inherits " + ", ".join(bases))
lines.append(" ".join(header_bits))
lines.append("")
loc = root.find("location")
if loc is not None and loc.attrib.get("file"):
lines.append(f"Defined in {src_link(loc)}")
lines.append("")
brief, _, _, _ = render_description(root.find("briefdescription"))
if brief:
lines.append(brief)
lines.append("")
detailed, _, _, _ = render_description(root.find("detaileddescription"))
if detailed:
lines.append(detailed)
lines.append("")
section_titles = {
"public-type": "Public Types",
"public-attrib": "Public Attributes",
"public-static-attrib": "Public Static Attributes",
"public-func": "Public Functions",
"public-static-func": "Public Static Functions",
"protected-attrib": "Protected Attributes",
"protected-func": "Protected Functions",
"private-attrib": "Private Attributes",
"private-func": "Private Functions",
"user-defined": "User-defined",
}
for sec in root.findall("sectiondef"):
sk = sec.attrib.get("kind", "")
title = section_titles.get(sk, sk.replace("-", " ").capitalize())
members = sec.findall("memberdef")
if not members:
continue
lines.append(f"## {title}")
lines.append("")
for m in members:
sig = member_signature(m)
mloc = m.find("location")
link = src_link(mloc) if mloc is not None else ""
lines.append(f"### `{sig}`")
if link:
lines.append("")
lines.append(f"📍 {link}")
lines.append("")
mbrief, _, _, _ = render_description(m.find("briefdescription"))
mdetail, params, returns, throws = render_description(m.find("detaileddescription"))
if mbrief:
lines.append(mbrief)
lines.append("")
if mdetail:
lines.append(mdetail)
lines.append("")
if params:
lines.append("**Parameters**")
lines.append("")
for pname, pdesc in params:
lines.append(f"- `{pname}` — {pdesc}" if pdesc else f"- `{pname}`")
lines.append("")
if returns:
lines.append("**Returns** " + " ".join(returns))
lines.append("")
if throws:
lines.append("**Throws**")
lines.append("")
for tname, tdesc in throws:
lines.append(f"- `{tname}` — {tdesc}" if tdesc else f"- `{tname}`")
lines.append("")
lines.append("---")
lines.append("")
lines.append("← [Back to classes](index.md) · [Top](../index.md)")
out_path.write_text("\n".join(lines).rstrip() + "\n")
def emit_file(xml_dir: Path, out_dir: Path, refid: str) -> None:
tree = ET.parse(xml_dir / f"{refid}.xml")
root = tree.find("compounddef")
if root is None:
return
name = (root.findtext("compoundname") or "").strip()
safe = refid # already a flat identifier like system_2modules_8hpp
out_path = out_dir / "files" / f"{safe}.md"
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
loc = root.find("location")
if loc is not None and loc.attrib.get("file"):
lines.append(f"Source: {src_link(loc)}")
lines.append("")
brief, _, _, _ = render_description(root.find("briefdescription"))
if brief:
lines.append(brief)
lines.append("")
detailed, _, _, _ = render_description(root.find("detaileddescription"))
if detailed:
lines.append(detailed)
lines.append("")
# Inner compounds (classes/structs/namespaces defined in this file).
inner = [(c.attrib.get("refid", ""), (c.text or "").strip())
for c in root.findall("innerclass")]
if inner:
lines.append("## Defines")
lines.append("")
for rid, label in inner:
lines.append(f"- {ref_to_md(rid, label)}")
lines.append("")
# Free functions, variables, typedefs at file scope.
for sec in root.findall("sectiondef"):
sk = sec.attrib.get("kind", "")
title_map = {
"func": "Free Functions",
"var": "Variables",
"typedef": "Typedefs",
"enum": "Enums",
"define": "Macros",
}
title = title_map.get(sk)
if not title:
continue
members = sec.findall("memberdef")
if not members:
continue
lines.append(f"## {title}")
lines.append("")
for m in members:
sig = member_signature(m)
mloc = m.find("location")
link = src_link(mloc) if mloc is not None else ""
lines.append(f"### `{sig}`")
if link:
lines.append("")
lines.append(f"📍 {link}")
lines.append("")
mbrief, _, _, _ = render_description(m.find("briefdescription"))
mdetail, params, returns, throws = render_description(m.find("detaileddescription"))
if mbrief:
lines.append(mbrief); lines.append("")
if mdetail:
lines.append(mdetail); lines.append("")
if params:
lines.append("**Parameters**"); lines.append("")
for pname, pdesc in params:
lines.append(f"- `{pname}` — {pdesc}" if pdesc else f"- `{pname}`")
lines.append("")
if returns:
lines.append("**Returns** " + " ".join(returns)); lines.append("")
if throws:
lines.append("**Throws**"); lines.append("")
for tname, tdesc in throws:
lines.append(f"- `{tname}` — {tdesc}" if tdesc else f"- `{tname}`")
lines.append("")
lines.append("---")
lines.append("")
lines.append("← [Back to files](index.md) · [Top](../index.md)")
out_path.write_text("\n".join(lines).rstrip() + "\n")
# ----------------------------------------------------------------------
# Index pages
# ----------------------------------------------------------------------
def emit_classes_index(out_dir: Path,
entries: list[tuple[str, str, str]]) -> None:
"""entries = list of (refid, name, brief)."""
lines: list[str] = []
lines.append("# Classes & Structs")
lines.append("")
lines.append("| Name | Brief |")
lines.append("|---|---|")
for refid, name, brief in sorted(entries, key=lambda e: e[1].lower()):
m = CLASS_REFID_RE.match(refid)
path = f"{m.group(2)}.md" if m else refid
lines.append(f"| [`{name}`]({path}) | {brief.replace('|', '\\|').strip() or ''} |")
lines.append("")
lines.append("← [Top](../index.md)")
(out_dir / "classes" / "index.md").write_text("\n".join(lines) + "\n")
def emit_files_index(out_dir: Path,
entries: list[tuple[str, str, str]]) -> None:
lines: list[str] = []
lines.append("# Source Files")
lines.append("")
lines.append("| Path | Brief |")
lines.append("|---|---|")
for refid, name, brief in sorted(entries, key=lambda e: e[1].lower()):
lines.append(f"| [`{name}`]({refid}.md) | {brief.replace('|', '\\|').strip() or ''} |")
lines.append("")
lines.append("← [Top](../index.md)")
(out_dir / "files" / "index.md").write_text("\n".join(lines) + "\n")
def emit_top_index(out_dir: Path, n_classes: int, n_files: int) -> None:
lines: list[str] = [
"# essim API reference",
"",
"Auto-generated from Doxygen comments in `src/`. Regenerate with",
"`cmake --build build --target doc`. See [doc/README.md](../README.md)",
"for the toolchain.",
"",
f"- [Classes & Structs](classes/index.md) — {n_classes} entries",
f"- [Source Files](files/index.md) — {n_files} entries",
"",
"## Curated reading order",
"",
"Start with the domain model, then importers, then the TUI:",
"",
"1. [`System`](classes/System.md) — owns Modules + Connections.",
"2. [`Module`](classes/Module.md) → [`Part`](classes/Part.md) → "
"[`Pin`](classes/Pin.md) → [`Signal`](classes/Signal.md) — ownership chain.",
"3. [`Connection`](classes/Connection.md) — cross-module wiring with `pin_map`.",
"4. [`Transform`](classes/Transform.md) / "
"[`IdentityTransform`](classes/IdentityTransform.md) — connector-pair → pin pairs.",
"5. [`ImportBase`](classes/ImportBase.md) → "
"[`ImportMentor`](classes/ImportMentor.md), "
"[`ImportAltium`](classes/ImportAltium.md), "
"[`ImportOds`](classes/ImportOds.md) — netlist parsers.",
"6. [`Tui`](classes/Tui.md) — interactive shell + screen orchestration.",
"",
]
(out_dir / "index.md").write_text("\n".join(lines) + "\n")
# ----------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------
def main() -> int:
if len(sys.argv) != 3:
print(__doc__, file=sys.stderr)
return 2
xml_dir = Path(sys.argv[1])
out_dir = Path(sys.argv[2])
if not xml_dir.is_dir():
print(f"xml_dir not found: {xml_dir}", file=sys.stderr)
return 1
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "classes").mkdir(exist_ok=True)
(out_dir / "files").mkdir(exist_ok=True)
index = ET.parse(xml_dir / "index.xml").getroot()
classes: list[tuple[str, str, str]] = [] # (refid, name, brief)
files: list[tuple[str, str, str]] = []
for c in index.findall("compound"):
kind = c.attrib.get("kind", "")
refid = c.attrib.get("refid", "")
name = (c.findtext("name") or "").strip()
if kind in ("class", "struct"):
# Read brief from the per-compound XML.
try:
tree = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef")
brief, _, _, _ = render_description(tree.find("briefdescription")) if tree is not None else ("", [], [], [])
except (FileNotFoundError, ET.ParseError):
brief = ""
classes.append((refid, name, brief))
emit_class(xml_dir, out_dir, refid)
elif kind == "file":
try:
tree = ET.parse(xml_dir / f"{refid}.xml").getroot().find("compounddef")
brief, _, _, _ = render_description(tree.find("briefdescription")) if tree is not None else ("", [], [], [])
except (FileNotFoundError, ET.ParseError):
brief = ""
files.append((refid, name, brief))
emit_file(xml_dir, out_dir, refid)
emit_classes_index(out_dir, classes)
emit_files_index(out_dir, files)
emit_top_index(out_dir, len(classes), len(files))
print(f"Wrote {len(classes)} class page(s) and {len(files)} file page(s) "
f"to {out_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())