feat(docs): generate Section 5 from function comment headers
The man-page-style comment header above each function in functions/*.fish becomes the SSOT for that function's documentation. Writing a new function and documenting it are now the same act. - manualtools.parse_functions() parses every header carrying a # CATEGORY; absence of one is the opt-in, keeping bundled-plugin and prompt internals out of the manual with no exclusion list to maintain. - build-manual.py generates entries for both --concat and --site, with **Dependencies:** rendered as links and a **Used by:** reverse index computed in one pass. Cross-category links are the navigation win. - docs/manual/05-functions/*.md reduced to frontmatter-only stubs. Every intro measured zero words, so the category files were pure entry containers; ordering, titles, and helpKeywords routing are untouched. - _first_sentence() unwraps the leading hard-wrapped paragraph and skips the whole Synopsis block, not just its label line. Site cards no longer truncate mid-clause or show a synopsis as their description. Verification, per the design spec: - test_concat_roundtrips_original scoped to sections 0-4 and 6-11. It guarded a format migration; this is a content migration. - replaced by structural checks: one entry per categorised function, the required sections present, every category resolving to a stub with no stub empty, and every declared dependency resolving to a real function or a type -q-guarded binary. - public functions lacking # CATEGORY warn rather than fail, so a new user-facing function going undocumented stays visible in CI. 24/24 checks pass. 94 entries generated from 94 parsed headers. Also drops a stale claim from open-url's NOTES: config-help --html calls xdg-open directly and has never called open-url.
This commit is contained in:
+84
-1
@@ -3,7 +3,8 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Shared helpers for the docs/manual SSOT pipeline.
|
||||
|
||||
Frontmatter parsing, deterministic tree ordering, and heading level shifts.
|
||||
Frontmatter parsing, deterministic tree ordering, heading level shifts, and
|
||||
the `functions/*.fish` comment-header parser that is the SSOT for Section 5.
|
||||
Used by build-manual.py and verify-manual.py.
|
||||
"""
|
||||
|
||||
@@ -57,6 +58,88 @@ def shift_headings(body: str, by: int) -> str:
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
HEADER_LABEL = re.compile(r"^#\s+([A-Z][A-Z ]*[A-Z])\s*$")
|
||||
FUNC_DEF = re.compile(r"^\s*function\s+(\S+)")
|
||||
SECTIONS = (
|
||||
"CATEGORY",
|
||||
"DEPENDENCIES",
|
||||
"SYNOPSIS",
|
||||
"DESCRIPTION",
|
||||
"ARGUMENTS",
|
||||
"RETURNS",
|
||||
"EXAMPLE",
|
||||
"NOTES",
|
||||
)
|
||||
|
||||
|
||||
def _header_blocks(lines: list[str]) -> list[tuple[int, dict[str, list[str]]]]:
|
||||
"""Find every man-page comment header in a file's lines.
|
||||
|
||||
Yields (index of the line that ended the block, {LABEL: body lines}).
|
||||
Body lines keep any indentation deeper than the standard `# ` prefix,
|
||||
which is what lets nested option tables survive into the rendered entry.
|
||||
Comment runs carrying no `# LABEL` line at all (the copyright preamble,
|
||||
ordinary inline comments) produce nothing.
|
||||
"""
|
||||
out: list[tuple[int, dict[str, list[str]]]] = []
|
||||
cur: dict[str, list[str]] = {}
|
||||
label: str | None = None
|
||||
for i, line in enumerate(lines + [""]):
|
||||
if not line.startswith("#"):
|
||||
if cur:
|
||||
out.append((i, cur))
|
||||
cur, label = {}, None
|
||||
continue
|
||||
m = HEADER_LABEL.match(line)
|
||||
if m:
|
||||
label = m.group(1)
|
||||
cur.setdefault(label, [])
|
||||
elif label is not None:
|
||||
body = line[1:]
|
||||
cur[label].append(body[3:] if body.startswith(" ") else body.strip())
|
||||
return out
|
||||
|
||||
|
||||
def _trailing_blanks(lines: list[str]) -> int:
|
||||
"""Count the blank `#` separator lines closing a section."""
|
||||
n = 0
|
||||
while n < len(lines) and not lines[len(lines) - 1 - n].strip():
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def parse_functions(root: Path) -> dict[str, dict[str, list[str]]]:
|
||||
"""Parse the comment header above every documented public function.
|
||||
|
||||
`root` is the repository's `functions/` directory. Returns
|
||||
`{name: {LABEL: [lines]}}`.
|
||||
|
||||
`# CATEGORY` is the opt-in: a header without one produces no entry. That
|
||||
keeps bundled-plugin and prompt internals (`fish_prompt`, `sponge_filter_*`,
|
||||
`fisher`, …) out of the manual with no exclusion list to maintain.
|
||||
|
||||
A file carrying exactly one header is associated with its own stem, so a
|
||||
`function` nested inside a `type -q` guard still resolves. Only files with
|
||||
several headers walk forward to the next `function` definition.
|
||||
"""
|
||||
out: dict[str, dict[str, list[str]]] = {}
|
||||
for path in sorted(root.glob("*.fish")):
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
blocks = _header_blocks(lines)
|
||||
for end, sections in blocks:
|
||||
if len(blocks) == 1:
|
||||
name = path.stem
|
||||
else:
|
||||
after = (m.group(1) for ln in lines[end:] if (m := FUNC_DEF.match(ln)))
|
||||
name = next(after, path.stem)
|
||||
if name.startswith("_") or "CATEGORY" not in sections:
|
||||
continue
|
||||
out[name] = {
|
||||
k: v[: len(v) - _trailing_blanks(v)] for k, v in sections.items()
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _sort_key(entry: Path) -> tuple:
|
||||
"""Order by sidebar.order when present, else by filename. Stable."""
|
||||
target = entry / "index.md" if entry.is_dir() else entry
|
||||
|
||||
Reference in New Issue
Block a user