docs: generate the Functions Reference from function comment headers #74
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user