53b43b0f79
Ship the QUALITY_PLAN stretch from ownership through ecosystem: C.1 lifetime elision (bootstrap + selfhost), bux fmt/test/doc CI hooks, stdlib goldens, package registry (bux search/add), and LSP 0.4 position-sensitive locals with inferred let types. Full-tree format pass plus Map/Set remove double-free fix.
203 lines
5.7 KiB
Nim
203 lines
5.7 KiB
Nim
## 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))
|