docs: generate the Functions Reference from function comment headers #74

Merged
rootiest merged 10 commits from docs-option-tables into main 2026-07-26 08:41:03 +00:00
2 changed files with 123 additions and 3 deletions
Showing only changes of commit 67fb29687c - Show all commits
+55
View File
@@ -140,6 +140,58 @@ def _is_shell(para: list[str], entry_name: str | None) -> bool:
return True
CELL_SPLIT = re.compile(r"\s{2,}")
def _cell(text: str, code: bool) -> str:
"""Render one table cell. `|` must be escaped even inside a code span."""
text = text.strip().replace("|", r"\|")
return f"`{text}`" if code and text else text
def _as_table(para: list[str]) -> str | None:
"""Render an aligned two-column block as a markdown table, else None.
Option and subcommand tables are the one thing in this manual that is
genuinely tabular, and the indented-code fallback renders them as a grey
slab. Everything else stays in that fallback: returning None is always
safe, so every check here is free to be conservative.
The rows must form one contiguous indented run, optionally introduced by
a label line (`Options:`) and closed by a sentence. Lines indented deeper
than the run are wrapped descriptions and fold into the row above.
"""
starts = [i for i, ln in enumerate(para) if ln.startswith(" ")]
if len(starts) < 2 or starts != list(range(starts[0], starts[-1] + 1)):
return None
head = para[: starts[0]]
body = para[starts[0] : starts[-1] + 1]
tail = para[starts[-1] + 1 :]
if head and not head[-1].rstrip().endswith(":"):
return None # a head that isn't a label means mixed content
indent = min(len(ln) - len(ln.lstrip()) for ln in body)
rows: list[list[str]] = []
for line in body:
if len(line) - len(line.lstrip()) > indent and rows:
rows[-1][1] += " " + line.strip()
continue
parts = CELL_SPLIT.split(line.strip(), 1)
if len(parts) != 2 or not parts[1].strip():
return None # not column-aligned; a numbered list, or prose
rows.append([parts[0], parts[1].strip()])
if len(rows) < 2:
return None
if any("<" in value or "{" in value for _, value in rows):
return None # live markdown in the prose column
out = [line.strip() for line in head]
out += ["| | |", "|---|---|"]
out += [f"| {_cell(k, True)} | {_cell(v, False)} |" for k, v in rows]
out += [line.strip() for line in tail]
return "\n".join(out)
def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str:
"""Render one paragraph of a former indented block.
@@ -152,6 +204,9 @@ def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str:
if _is_shell(para, entry_name):
body = "\n".join(para)
return f"```fish\n{body}\n```"
table = _as_table(para)
if table is not None:
return table
return "\n".join(INDENT + line for line in para)
+68 -3
View File
@@ -237,14 +237,79 @@ def test_prettify_splits_an_entry_block():
assert "```fish\nrm file.txt" in out, "examples were not fenced as fish"
assert out.count("```") == 4, f"expected exactly two fences, got:\n{out}"
assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented"
assert (
"\n (no args) List current trash contents" in out
), "option table lost its indentation"
assert "| `(no args)` | List current trash contents |" in out, (
"option table was not converted to a markdown table"
)
assert (
"\nFalls back to /usr/bin/rm when trash is unavailable." in out
), "trailing prose stayed indented"
def test_as_table_converts_option_blocks():
"""A labelled, column-aligned block becomes a table; wrapped rows fold in."""
import build_manual
out = build_manual._as_table(
[
"Options:",
" -a/--aggressive Also removes node_modules, logs,",
" and IDE dirs",
" -d/--dry-run Print what would be removed",
"Pass neither to run interactively.",
]
)
assert out is not None, "a plain option table was rejected"
assert out.splitlines()[0] == "Options:", "the label line was dropped"
assert out.splitlines()[-1] == "Pass neither to run interactively.", (
"the trailing sentence was dropped"
)
assert (
"| `-a/--aggressive` | Also removes node_modules, logs, and IDE dirs |" in out
), "a wrapped description did not fold into the row above"
def test_as_table_escapes_pipes():
"""`|` splits table cells even inside a code span, so it must be escaped."""
import build_manual
out = build_manual._as_table(
[" -r/-R Recurse into it", " -e|-E Empty it"]
)
assert out is not None and r"`-e\|-E`" in out, f"pipe was not escaped:\n{out}"
def test_as_table_rejects_non_tables():
"""Returning None is always safe, so every ambiguous shape must return it."""
import build_manual
cases = {
"single row": [" -f/--force Force-delete unmerged branches too"],
"numbered list": [
" 1. git+cargo source build (fish shell itself)",
" 2. cargo (Rust tools — gets latest crate version)",
],
"misaligned rows": [
" -e/--empty Empty the trash",
" -S/--secure Permanently delete (single space, not a column)",
],
"synopsis continuation": [
" auto-pull add [PATH]",
" auto-pull status",
],
"unlabelled head": [
"Routes to the best tool by context.",
" --disk force duf",
" --dir force dust",
],
"live markdown in prose column": [
" add Register <PATH>'s git root",
" remove Unregister by basename",
],
}
for label, para in cases.items():
assert build_manual._as_table(para) is None, f"{label} was wrongly tabled"
def test_prettify_leaves_reference_tables_alone():
"""Column-aligned blocks are data, not shell, and must not be fenced."""
import build_manual