|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate Markdown API docs for the F# SDK by scraping source. |
| 3 | +
|
| 4 | +Walks src/<Project>/**/*.fs, extracts namespace/module declarations and |
| 5 | +`///` doc-comment blocks attached to public `let` / `type` / `module` / |
| 6 | +`member` / `val` / `abstract` / `new` declarations, then writes one |
| 7 | +Markdown file per project plus a top-level index. |
| 8 | +
|
| 9 | +Output: docs/api/<Project>.md and docs/api/index.md. |
| 10 | +""" |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import re |
| 14 | +from dataclasses import dataclass, field |
| 15 | +from pathlib import Path |
| 16 | +from typing import Iterator |
| 17 | + |
| 18 | +ROOT = Path(__file__).resolve().parent.parent |
| 19 | +SRC = ROOT / "src" |
| 20 | +OUT = ROOT / "docs" / "api" |
| 21 | + |
| 22 | +DECL_RE = re.compile( |
| 23 | + r"^(?P<indent>\s*)" |
| 24 | + r"(?P<kw>let|type|module|member|val|abstract|new|and|interface)\b" |
| 25 | + r"(?P<rest>.*)$" |
| 26 | +) |
| 27 | +ATTR_RE = re.compile(r"^\s*\[<[^>]+>\]\s*$") |
| 28 | +NS_RE = re.compile(r"^namespace\s+(?P<ns>\S+)") |
| 29 | +TOP_MODULE_RE = re.compile(r"^module\s+(?P<m>[A-Za-z0-9_.]+)\s*=?\s*$") |
| 30 | +# `let private name` / `member private name` is private. A `type Foo |
| 31 | +# private () =` is NOT — that only marks the primary ctor private. |
| 32 | +LET_MEMBER_PRIV_RE = re.compile(r"^\s+(private|internal)\b") |
| 33 | +TYPE_MODULE_PRIV_RE = re.compile(r"^\s+(private|internal)\b\s+[A-Za-z_]") |
| 34 | + |
| 35 | + |
| 36 | +@dataclass |
| 37 | +class Decl: |
| 38 | + kind: str |
| 39 | + signature: str |
| 40 | + doc: list[str] = field(default_factory=list) |
| 41 | + scope: str = "" |
| 42 | + |
| 43 | + |
| 44 | +@dataclass |
| 45 | +class FileDoc: |
| 46 | + path: Path |
| 47 | + namespace: str = "" |
| 48 | + top_module: str = "" |
| 49 | + decls: list[Decl] = field(default_factory=list) |
| 50 | + |
| 51 | + |
| 52 | +def strip_doc(line: str) -> str: |
| 53 | + s = line.lstrip() |
| 54 | + return s[3:].lstrip() if s.startswith("///") else "" |
| 55 | + |
| 56 | + |
| 57 | +def is_continuation(line: str) -> bool: |
| 58 | + t = line.rstrip() |
| 59 | + return bool(t) and t.endswith(("(", ",", "->", ":", "*", "<", "=")) |
| 60 | + |
| 61 | + |
| 62 | +def is_private(kw: str, rest: str) -> bool: |
| 63 | + if kw in ("let", "member", "val", "abstract"): |
| 64 | + return bool(LET_MEMBER_PRIV_RE.match(rest)) |
| 65 | + return bool(TYPE_MODULE_PRIV_RE.match(rest)) |
| 66 | + |
| 67 | + |
| 68 | +def gather_signature(lines: list[str], i: int) -> str: |
| 69 | + parts = [lines[i].rstrip()] |
| 70 | + j = i |
| 71 | + while is_continuation(parts[-1]) and j + 1 < len(lines): |
| 72 | + j += 1 |
| 73 | + nxt = lines[j].rstrip() |
| 74 | + if nxt.lstrip().startswith("///"): |
| 75 | + break |
| 76 | + parts.append(nxt) |
| 77 | + if len(parts) >= 6: |
| 78 | + break |
| 79 | + return "\n".join(parts).strip() |
| 80 | + |
| 81 | + |
| 82 | +def compute_scope(fd: FileDoc, module_stack: list[tuple[int, str]], kw: str) -> str: |
| 83 | + parts: list[str] = [] |
| 84 | + if fd.namespace: |
| 85 | + parts.append(fd.namespace) |
| 86 | + if fd.top_module: |
| 87 | + parts.append(fd.top_module) |
| 88 | + if kw == "module": |
| 89 | + parts.extend(n for _, n in module_stack[:-1]) |
| 90 | + else: |
| 91 | + parts.extend(n for _, n in module_stack) |
| 92 | + return ".".join(parts) |
| 93 | + |
| 94 | + |
| 95 | +def parse_file(path: Path) -> FileDoc: |
| 96 | + """Extract documented declarations from a single .fs file.""" |
| 97 | + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() |
| 98 | + fd = FileDoc(path=path) |
| 99 | + pending: list[str] = [] |
| 100 | + module_stack: list[tuple[int, str]] = [] |
| 101 | + i = 0 |
| 102 | + while i < len(lines): |
| 103 | + raw = lines[i] |
| 104 | + stripped = raw.strip() |
| 105 | + |
| 106 | + if not stripped: |
| 107 | + i += 1 |
| 108 | + continue |
| 109 | + |
| 110 | + m_ns = NS_RE.match(stripped) |
| 111 | + if m_ns: |
| 112 | + fd.namespace = m_ns.group("ns") |
| 113 | + pending = [] |
| 114 | + i += 1 |
| 115 | + continue |
| 116 | + |
| 117 | + # Capture the file-level top module only when there is no |
| 118 | + # namespace declaration (i.e. file starts with `module X.Y.Z`). |
| 119 | + # When a namespace is present, col-0 `module Foo =` lines are |
| 120 | + # sibling modules, not file-level wrappers. |
| 121 | + m_top = TOP_MODULE_RE.match(stripped) |
| 122 | + if ( |
| 123 | + m_top |
| 124 | + and not raw.startswith(" ") |
| 125 | + and not fd.top_module |
| 126 | + and not fd.namespace |
| 127 | + ): |
| 128 | + fd.top_module = m_top.group("m") |
| 129 | + |
| 130 | + if stripped.startswith("///"): |
| 131 | + pending.append(strip_doc(raw)) |
| 132 | + i += 1 |
| 133 | + continue |
| 134 | + |
| 135 | + if ATTR_RE.match(raw): |
| 136 | + i += 1 |
| 137 | + continue |
| 138 | + |
| 139 | + m = DECL_RE.match(raw) |
| 140 | + if m: |
| 141 | + indent = len(m.group("indent").expandtabs(4)) |
| 142 | + kw = m.group("kw") |
| 143 | + rest = m.group("rest") |
| 144 | + |
| 145 | + while module_stack and indent <= module_stack[-1][0]: |
| 146 | + module_stack.pop() |
| 147 | + |
| 148 | + if kw == "module": |
| 149 | + nm = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*)", rest) |
| 150 | + if nm: |
| 151 | + module_stack.append((indent, nm.group(1))) |
| 152 | + |
| 153 | + if pending and not is_private(kw, rest): |
| 154 | + fd.decls.append( |
| 155 | + Decl( |
| 156 | + kind=kw, |
| 157 | + signature=gather_signature(lines, i), |
| 158 | + doc=list(pending), |
| 159 | + scope=compute_scope(fd, module_stack, kw), |
| 160 | + ) |
| 161 | + ) |
| 162 | + |
| 163 | + pending = [] |
| 164 | + i += 1 |
| 165 | + continue |
| 166 | + |
| 167 | + # Non-doc/attr/decl line drops any pending docs. |
| 168 | + pending = [] |
| 169 | + i += 1 |
| 170 | + return fd |
| 171 | + |
| 172 | + |
| 173 | +def iter_projects() -> Iterator[Path]: |
| 174 | + for d in sorted(SRC.iterdir()): |
| 175 | + if d.is_dir(): |
| 176 | + yield d |
| 177 | + |
| 178 | + |
| 179 | +def render_project(project_dir: Path) -> tuple[str, str, str]: |
| 180 | + """Return (name, markdown, summary) for one project.""" |
| 181 | + name = project_dir.name |
| 182 | + out: list[str] = [f"# {name}", ""] |
| 183 | + summary = "" |
| 184 | + for f in sorted(project_dir.rglob("*.fs")): |
| 185 | + if f.name == "AssemblyInfo.fs": |
| 186 | + continue |
| 187 | + fd = parse_file(f) |
| 188 | + if not fd.decls: |
| 189 | + continue |
| 190 | + rel = f.relative_to(project_dir) |
| 191 | + out.append(f"## `{rel.as_posix()}`") |
| 192 | + out.append("") |
| 193 | + if fd.namespace: |
| 194 | + out.append(f"_namespace_ `{fd.namespace}`") |
| 195 | + out.append("") |
| 196 | + if fd.top_module: |
| 197 | + out.append(f"_module_ `{fd.top_module}`") |
| 198 | + out.append("") |
| 199 | + if not summary and fd.decls[0].doc: |
| 200 | + summary = fd.decls[0].doc[0] |
| 201 | + for d in fd.decls: |
| 202 | + heading = d.scope or fd.namespace or name |
| 203 | + out.append(f"### `{d.kind}` in `{heading}`") |
| 204 | + out.append("") |
| 205 | + out.append("```fsharp") |
| 206 | + out.append(d.signature) |
| 207 | + out.append("```") |
| 208 | + out.append("") |
| 209 | + for line in d.doc: |
| 210 | + out.append(line) |
| 211 | + out.append("") |
| 212 | + return name, "\n".join(out).rstrip() + "\n", summary |
| 213 | + |
| 214 | + |
| 215 | +def main() -> None: |
| 216 | + OUT.mkdir(parents=True, exist_ok=True) |
| 217 | + index: list[tuple[str, str]] = [] |
| 218 | + written = 0 |
| 219 | + for project in iter_projects(): |
| 220 | + name, md, summary = render_project(project) |
| 221 | + if md.strip() == f"# {name}": |
| 222 | + continue |
| 223 | + (OUT / f"{name}.md").write_text(md, encoding="utf-8") |
| 224 | + index.append((name, summary)) |
| 225 | + written += 1 |
| 226 | + |
| 227 | + idx = [ |
| 228 | + "# ARCP F# SDK API Reference", |
| 229 | + "", |
| 230 | + "Auto-generated from F# `///` doc comments. Regenerate with `make docs-api`.", |
| 231 | + "", |
| 232 | + "## Projects", |
| 233 | + "", |
| 234 | + ] |
| 235 | + for name, summary in index: |
| 236 | + line = f"- [{name}]({name}.md)" |
| 237 | + if summary: |
| 238 | + line += f" — {summary}" |
| 239 | + idx.append(line) |
| 240 | + idx.append("") |
| 241 | + (OUT / "index.md").write_text("\n".join(idx), encoding="utf-8") |
| 242 | + print(f"Wrote {written} project page(s) + index.md to {OUT}") |
| 243 | + |
| 244 | + |
| 245 | +if __name__ == "__main__": |
| 246 | + main() |
0 commit comments