## docgen.nim — Extract `///` (and adjacent `/* */`) docs into Markdown. ## Used by `bux doc [path...]`. import std/[os, strutils, strformat, algorithm] type DocItem* = object kind*: string ## module | func | struct | enum | interface | extern name*: string signature*: string ## first declaration line (trimmed) docs*: string file*: string line*: int proc isDeclStart(line: string): bool = let s = line.strip() if s.len == 0: return false # Leading attributes @[Checked] etc. — not a decl by themselves if s.startsWith("@["): return false if s.startsWith("func ") or s.startsWith("pub func ") or s.startsWith("extern func ") or s.startsWith("const func ") or s.startsWith("async func "): return true if s.startsWith("struct ") or s.startsWith("pub struct ") or s.startsWith("enum ") or s.startsWith("pub enum ") or s.startsWith("union ") or s.startsWith("interface ") or s.startsWith("module ") or s.startsWith("type "): return true return false proc declKindAndName(line: string): tuple[kind, name: string] = var s = line.strip() # Strip leading pub/extern/const/async for prefix in ["pub ", "extern ", "const ", "async "]: if s.startsWith(prefix): s = s[prefix.len .. ^1].strip() var kind = "item" if s.startsWith("func "): kind = "func" s = s["func ".len .. ^1] elif s.startsWith("struct "): kind = "struct" s = s["struct ".len .. ^1] elif s.startsWith("enum "): kind = "enum" s = s["enum ".len .. ^1] elif s.startsWith("union "): kind = "union" s = s["union ".len .. ^1] elif s.startsWith("interface "): kind = "interface" s = s["interface ".len .. ^1] elif s.startsWith("module "): kind = "module" s = s["module ".len .. ^1] elif s.startsWith("type "): kind = "type" s = s["type ".len .. ^1] # Name: until `<` `(` `{` `:` space var name = "" for ch in s: if ch in {' ', '<', '(', '{', ':', ';'}: break name.add(ch) if name.len == 0: name = s # extern funcs already stripped "extern " if kind == "func" and line.strip().startsWith("extern"): kind = "extern" return (kind, name) proc extractDocsFromSource*(source, path: string): seq[DocItem] = result = @[] var pending: seq[string] = @[] var inBlockComment = false var blockDoc: seq[string] = @[] var lineNo = 0 for rawLine in source.splitLines(): inc lineNo var line = rawLine let stripped = line.strip() # Block comment handling (/* ... */ used in stdlib today) if inBlockComment: let endIdx = stripped.find("*/") if endIdx >= 0: let before = stripped[0 ..< endIdx].strip() if before.len > 0: blockDoc.add(before) inBlockComment = false # Treat completed block as pending doc if non-empty if blockDoc.len > 0: pending = blockDoc blockDoc = @[] continue else: blockDoc.add(stripped) continue if stripped.startsWith("/*") and not stripped.startsWith("/***"): let rest = stripped["/*".len .. ^1] let endIdx = rest.find("*/") if endIdx >= 0: let body = rest[0 ..< endIdx].strip() if body.len > 0: pending = @[body] else: inBlockComment = true blockDoc = @[] let body = rest.strip() if body.len > 0: blockDoc.add(body) continue # Triple-slash doc comments if stripped.startsWith("///"): var body = stripped["///".len .. ^1] if body.startsWith(" "): body = body[1 .. ^1] pending.add(body) continue # Empty line: keep pending docs (allow blank lines inside doc blocks) if stripped.len == 0: continue # Attributes immediately before decl: keep pending if stripped.startsWith("@["): continue if isDeclStart(line): if pending.len > 0: let (kind, name) = declKindAndName(line) result.add(DocItem( kind: kind, name: name, signature: stripped, docs: pending.join("\n"), file: path, line: lineNo )) pending = @[] continue # Other code clears pending (except plain // comments) if stripped.startsWith("//"): continue pending = @[] proc collectBuxFilesForDoc*(root: string): seq[string] = result = @[] if fileExists(root) and root.endsWith(".bux"): result.add(root) return if not dirExists(root): return for path in walkDirRec(root): if path.endsWith(".bux"): result.add(path) result.sort(system.cmp) proc renderMarkdown*(items: seq[DocItem], title: string = "API Reference"): string = var sb: string sb.add(&"# {title}\n\n") sb.add("Generated by `bux doc` from `///` and `/* */` documentation comments.\n\n") if items.len == 0: sb.add("_No documented items found._\n") return sb # Group by file var byFile: seq[string] = @[] for it in items: if it.file notin byFile: byFile.add(it.file) byFile.sort(system.cmp) for f in byFile: let base = splitFile(f).name sb.add(&"## `{base}`\n\n") sb.add(&"_Source: `{f}`_\n\n") for it in items: if it.file != f: continue sb.add(&"### `{it.name}` _{it.kind}_\n\n") sb.add("```bux\n") sb.add(it.signature) sb.add("\n```\n\n") if it.docs.len > 0: sb.add(it.docs) sb.add("\n\n") return sb proc generateDocs*(paths: seq[string]): seq[DocItem] = result = @[] var files: seq[string] = @[] for p in paths: for f in collectBuxFilesForDoc(p): if f notin files: files.add(f) files.sort(system.cmp) for f in files: let src = readFile(f) result.add(extractDocsFromSource(src, f))