feat(docs): parse # COMPONENT headers in manualtools
This commit is contained in:
+72
-5
@@ -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+)")
|
FUNC_DEF = re.compile(r"^\s*function\s+(\S+)")
|
||||||
SECTIONS = (
|
SECTIONS = (
|
||||||
"CATEGORY",
|
"CATEGORY",
|
||||||
|
"COMPONENT",
|
||||||
"DEPENDENCIES",
|
"DEPENDENCIES",
|
||||||
"SYNOPSIS",
|
"SYNOPSIS",
|
||||||
"DESCRIPTION",
|
"DESCRIPTION",
|
||||||
@@ -109,6 +110,19 @@ def _trailing_blanks(lines: list[str]) -> int:
|
|||||||
return n
|
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]]]:
|
def parse_functions(root: Path) -> dict[str, dict[str, list[str]]]:
|
||||||
"""Parse the comment header above every documented public function.
|
"""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")
|
lines = path.read_text(encoding="utf-8").split("\n")
|
||||||
blocks = _header_blocks(lines)
|
blocks = _header_blocks(lines)
|
||||||
for end, sections in blocks:
|
for end, sections in blocks:
|
||||||
if len(blocks) == 1:
|
name = _block_identity(path, lines, end, len(blocks))
|
||||||
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:
|
if name.startswith("_") or "CATEGORY" not in sections:
|
||||||
continue
|
continue
|
||||||
out[name] = {
|
out[name] = {
|
||||||
@@ -185,6 +195,63 @@ def parse_abbreviations(root: Path) -> dict[str, list[dict]]:
|
|||||||
return out
|
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 <slug>: <tag>` scopes <tag> to that site; a
|
||||||
|
bare `<tag>` 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:
|
def _sort_key(entry: Path) -> tuple:
|
||||||
"""Order by sidebar.order when present, else by filename. Stable."""
|
"""Order by sidebar.order when present, else by filename. Stable."""
|
||||||
target = entry / "index.md" if entry.is_dir() else entry
|
target = entry / "index.md" if entry.is_dir() else entry
|
||||||
|
|||||||
@@ -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_")]
|
TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user