|
| 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