Skip to content

Commit 79efe27

Browse files
nficanoclaude
andcommitted
chore: add Markdown API doc generation tooling
Add a Makefile and scripts/gen-api-docs.py to render the API reference into docs/api/ for the www site; gitignore the generated output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c92b57e commit 79efe27

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
*.swp
55
*.swo
66
.env
7+
8+
# Generated API docs (scripts/gen-api-docs.py)
9+
/docs/api/

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.PHONY: docs-api docs-api-clean
2+
3+
# Regenerate per-crate Markdown API summaries under docs/api/.
4+
# Output is consumed by the www site at build time.
5+
docs-api:
6+
@python3 scripts/gen-api-docs.py
7+
8+
docs-api-clean:
9+
@rm -rf docs/api

scripts/gen-api-docs.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
"""Generate Markdown API docs for each workspace crate.
3+
4+
For each `crates/<crate>/`, scans every `.rs` file under `src/` for:
5+
- the crate-level `//!` doc-comment (from `src/lib.rs`)
6+
- public items (`pub fn|struct|enum|trait|mod|type|const|static|union`)
7+
and the `///` doc-comment immediately preceding each.
8+
9+
Emits `docs/api/<crate>.md` (one per crate) and `docs/api/index.md`.
10+
Heavy lifting (signatures, generics, trait impls, fully-rendered docs)
11+
is intentionally deferred to docs.rs — every page links there.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import re
17+
import sys
18+
import tomllib
19+
from pathlib import Path
20+
21+
CRATES = [
22+
"arcp",
23+
"arcp-core",
24+
"arcp-client",
25+
"arcp-runtime",
26+
"arcp-tower",
27+
"arcp-axum",
28+
"arcp-actix-web",
29+
"arcp-otel",
30+
]
31+
KIND_ORDER = ["mod", "struct", "enum", "trait", "fn", "type", "const", "static", "union"]
32+
KIND_HEADINGS = {
33+
"mod": "Modules", "struct": "Structs", "enum": "Enums",
34+
"trait": "Traits", "fn": "Functions", "type": "Type Aliases",
35+
"const": "Constants", "static": "Statics", "union": "Unions",
36+
}
37+
PUB_ITEM_RE = re.compile(
38+
r"^\s*pub(?:\s*\([^)]*\))?\s+(?:async\s+|unsafe\s+|const\s+|extern(?:\s+\"[^\"]+\")?\s+)*"
39+
r"(?P<kind>fn|struct|enum|trait|mod|type|const|static|union)\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
40+
)
41+
42+
43+
def first_line(text: str) -> str:
44+
for line in text.splitlines():
45+
s = line.strip()
46+
if s:
47+
return s
48+
return ""
49+
50+
51+
def extract_crate_doc(lib_rs: Path) -> str:
52+
if not lib_rs.exists():
53+
return ""
54+
lines = []
55+
for raw in lib_rs.read_text(encoding="utf-8").splitlines():
56+
s = raw.lstrip()
57+
if s.startswith("//!"):
58+
lines.append(s[3:].lstrip() if not s.startswith("//!!") else s[3:])
59+
elif s == "" and lines:
60+
lines.append("")
61+
elif lines:
62+
break
63+
return "\n".join(lines).rstrip()
64+
65+
66+
def extract_items(src_dir: Path) -> list[dict]:
67+
items: list[dict] = []
68+
for path in sorted(src_dir.rglob("*.rs")):
69+
text = path.read_text(encoding="utf-8")
70+
lines = text.splitlines()
71+
doc_buf: list[str] = []
72+
for line in lines:
73+
stripped = line.lstrip()
74+
if stripped.startswith("///") and not stripped.startswith("////"):
75+
doc_buf.append(stripped[3:].lstrip())
76+
continue
77+
m = PUB_ITEM_RE.match(line)
78+
if m and not stripped.startswith("//"):
79+
summary = first_line("\n".join(doc_buf))
80+
items.append({
81+
"kind": m.group("kind"),
82+
"name": m.group("name"),
83+
"summary": summary,
84+
"file": path.relative_to(src_dir.parent).as_posix(),
85+
})
86+
doc_buf = []
87+
continue
88+
if not stripped.startswith("#["):
89+
doc_buf = []
90+
# de-dup by (kind, name) keeping first occurrence with non-empty summary preference
91+
seen: dict[tuple[str, str], dict] = {}
92+
for it in items:
93+
key = (it["kind"], it["name"])
94+
if key not in seen or (not seen[key]["summary"] and it["summary"]):
95+
seen[key] = it
96+
return list(seen.values())
97+
98+
99+
def render_crate_md(crate: str, cargo_toml: dict, src_dir: Path) -> str:
100+
pkg = cargo_toml.get("package", {})
101+
description = pkg.get("description", "")
102+
crate_doc = extract_crate_doc(src_dir / "lib.rs")
103+
items = extract_items(src_dir)
104+
105+
out: list[str] = []
106+
out.append(f"# `{crate}`")
107+
out.append("")
108+
if description:
109+
out.append(f"> {description}")
110+
out.append("")
111+
out.append(f"**Full API reference:** [docs.rs/{crate}](https://docs.rs/{crate})")
112+
out.append("")
113+
if crate_doc:
114+
out.append("## Overview")
115+
out.append("")
116+
out.append(crate_doc)
117+
out.append("")
118+
119+
grouped: dict[str, list[dict]] = {k: [] for k in KIND_ORDER}
120+
for it in items:
121+
grouped.setdefault(it["kind"], []).append(it)
122+
123+
total = sum(len(v) for v in grouped.values())
124+
if total == 0:
125+
out.append("## Public items")
126+
out.append("")
127+
out.append("_This crate re-exports another crate and exposes no items of its own._")
128+
out.append("")
129+
return "\n".join(out).rstrip() + "\n"
130+
131+
out.append("## Public items")
132+
out.append("")
133+
for kind in KIND_ORDER:
134+
bucket = sorted(grouped.get(kind, []), key=lambda i: i["name"])
135+
if not bucket:
136+
continue
137+
out.append(f"### {KIND_HEADINGS[kind]}")
138+
out.append("")
139+
for it in bucket:
140+
line = f"- `{it['name']}`"
141+
if it["summary"]:
142+
line += f" — {it['summary']}"
143+
out.append(line)
144+
out.append("")
145+
return "\n".join(out).rstrip() + "\n"
146+
147+
148+
def main() -> int:
149+
root = Path(__file__).resolve().parent.parent
150+
out_dir = root / "docs" / "api"
151+
out_dir.mkdir(parents=True, exist_ok=True)
152+
153+
index_rows: list[tuple[str, str]] = []
154+
for crate in CRATES:
155+
crate_dir = root / "crates" / crate
156+
cargo_path = crate_dir / "Cargo.toml"
157+
if not cargo_path.exists():
158+
print(f"skip: {crate} (no Cargo.toml)", file=sys.stderr)
159+
continue
160+
cargo = tomllib.loads(cargo_path.read_text(encoding="utf-8"))
161+
md = render_crate_md(crate, cargo, crate_dir / "src")
162+
(out_dir / f"{crate}.md").write_text(md, encoding="utf-8")
163+
index_rows.append((crate, cargo.get("package", {}).get("description", "")))
164+
print(f"wrote docs/api/{crate}.md")
165+
166+
index: list[str] = []
167+
index.append("# Rust SDK — API Reference")
168+
index.append("")
169+
index.append(
170+
"Per-crate summaries of public items. Generated from each crate's "
171+
"`src/` by `scripts/gen-api-docs.py`. Full signatures, generics, and "
172+
"rendered doc-comments live on [docs.rs](https://docs.rs)."
173+
)
174+
index.append("")
175+
index.append("## Crates")
176+
index.append("")
177+
for crate, desc in index_rows:
178+
suffix = f" — {desc}" if desc else ""
179+
index.append(f"- [`{crate}`]({crate}.md){suffix}")
180+
index.append("")
181+
(out_dir / "index.md").write_text("\n".join(index), encoding="utf-8")
182+
print("wrote docs/api/index.md")
183+
return 0
184+
185+
186+
if __name__ == "__main__":
187+
raise SystemExit(main())

0 commit comments

Comments
 (0)