From 31b47ddfc5ff818e6b04886faa83a10506c9e033 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 17 Aug 2026 20:29:02 -0400 Subject: [PATCH] feat(docs): parse # COMPONENT headers in manualtools --- docs/manualtools.py | 77 ++++++++++++++++++++++++++++++++++++++++--- docs/verify-manual.py | 73 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/docs/manualtools.py b/docs/manualtools.py index 7103653..70054a2 100644 --- a/docs/manualtools.py +++ b/docs/manualtools.py @@ -62,6 +62,7 @@ HEADER_LABEL = re.compile(r"^#\s+([A-Z][A-Z ]*[A-Z])\s*$") FUNC_DEF = re.compile(r"^\s*function\s+(\S+)") SECTIONS = ( "CATEGORY", + "COMPONENT", "DEPENDENCIES", "SYNOPSIS", "DESCRIPTION", @@ -109,6 +110,19 @@ def _trailing_blanks(lines: list[str]) -> int: return n +def _block_identity(path: Path, lines: list[str], end: int, blocks_count: int) -> str: + """Resolve a header block's associated name. + + A file carrying exactly one header is associated with its own stem, so + a `function` nested inside a `type -q` guard still resolves. A file + with several headers walks forward to the next `function` definition. + """ + if blocks_count == 1: + return path.stem + after = (m.group(1) for ln in lines[end:] if (m := FUNC_DEF.match(ln))) + return next(after, path.stem) + + def parse_functions(root: Path) -> dict[str, dict[str, list[str]]]: """Parse the comment header above every documented public function. @@ -128,11 +142,7 @@ def parse_functions(root: Path) -> dict[str, dict[str, list[str]]]: 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) + name = _block_identity(path, lines, end, len(blocks)) if name.startswith("_") or "CATEGORY" not in sections: continue out[name] = { @@ -185,6 +195,63 @@ def parse_abbreviations(root: Path) -> dict[str, list[dict]]: return out +SITE_LINE_RE = re.compile(r"^site\s+(\S+):\s*(\S+)$") + + +def parse_component_lines(lines: list[str]) -> list[tuple[str, str]]: + """Parse raw `# COMPONENT` body lines into (site, tag) pairs. + + A line of the form `site : ` scopes to that site; a + bare `` line belongs to the default (unnamed) site, keyed "". + Blank lines are skipped. + """ + out: list[tuple[str, str]] = [] + for line in lines: + line = line.strip() + if not line: + continue + m = SITE_LINE_RE.match(line) + if m: + out.append((m.group(1), m.group(2))) + else: + out.append(("", line)) + return out + + +def _parse_component_blocks(path: Path) -> dict[str, list[str]]: + """Parse every `# COMPONENT` header block in one file. + + Unlike parse_functions, there is no `# CATEGORY` gate and no + underscore exclusion: component classification applies to every + function/script, public or private, documented in the manual or not + -- the registry needs to see every guarded identity, not just the + ones that appear in the public function reference. + """ + lines = path.read_text(encoding="utf-8").split("\n") + blocks = _header_blocks(lines) + out: dict[str, list[str]] = {} + for end, sections in blocks: + if "COMPONENT" not in sections: + continue + name = _block_identity(path, lines, end, len(blocks)) + body = sections["COMPONENT"] + out[name] = body[: len(body) - _trailing_blanks(body)] + return out + + +def parse_component_file(path: Path) -> dict[str, list[str]]: + """Parse `# COMPONENT` header block(s) in one specific file (e.g. config.fish).""" + return _parse_component_blocks(path) + + +def parse_components(root: Path) -> dict[str, list[str]]: + """Parse `# COMPONENT` header blocks across every `*.fish` file under root.""" + out: dict[str, list[str]] = {} + for path in sorted(root.glob("*.fish")): + out.update(_parse_component_blocks(path)) + 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 diff --git a/docs/verify-manual.py b/docs/verify-manual.py index d242012..ae5810b 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -827,6 +827,79 @@ def test_site_avoids_reserved_dir(): ) +def test_parse_component_lines_default_and_named_sites(): + lines = [ + "aliases/filesystem", + "site exit-plain: overrides/key-bindings", + "site logging-guard: logging/terminal-capture", + "", + " ", + ] + got = mt.parse_component_lines(lines) + assert got == [ + ("", "aliases/filesystem"), + ("exit-plain", "overrides/key-bindings"), + ("logging-guard", "logging/terminal-capture"), + ], f"unexpected parse: {got}" + + +def test_parse_components_includes_underscore_prefixed_and_uncategorised(): + """Unlike parse_functions, parse_components has no # CATEGORY gate and + no underscore exclusion -- every guarded identity must be visible.""" + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "__private_helper.fish").write_text( + "# COMPONENT\n" + "# logging/terminal-capture\n" + "function __private_helper\n" + "end\n" + ) + (root / "no_category.fish").write_text( + "# COMPONENT\n" + "# aliases/filesystem\n" + "#\n" + "# SYNOPSIS\n" + "# no_category\n" + "function no_category\n" + "end\n" + ) + got = mt.parse_components(root) + assert got["__private_helper"] == ["logging/terminal-capture"] + assert got["no_category"] == ["aliases/filesystem"] + + +def test_parse_components_resolves_multi_header_file_to_function_name(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "multi.fish").write_text( + "# COMPONENT\n" + "# aliases/filesystem\n" + "function first_fn\n" + "end\n" + "\n" + "# COMPONENT\n" + "# aliases/network\n" + "function second_fn\n" + "end\n" + ) + got = mt.parse_components(root) + assert got == { + "first_fn": ["aliases/filesystem"], + "second_fn": ["aliases/network"], + }, f"unexpected resolution: {got}" + + +def test_parse_component_file_single_file(): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "config.fish" + path.write_text( + "# COMPONENT\n" + "# site greeting-block: greeting/greeting-message\n" + ) + got = mt.parse_component_file(path) + assert got == {"config": ["site greeting-block: greeting/greeting-message"]} + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]