feat(docs): add component registry generator
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (C) 2026 Rootiest
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Generate the committed opinionated-component registry.
|
||||
|
||||
Walks every `# COMPONENT` header in functions/*.fish, conf.d/*.fish, and
|
||||
config.fish and writes conf.d/__fish_config_op_registry.fish, the fish
|
||||
data file __fish_config_op_registry_lookup reads at shell startup.
|
||||
|
||||
Run manually (via __fish_config_op_registry_rebuild) after editing a
|
||||
# COMPONENT header, and automatically as a pre-step in build-manual.py
|
||||
before the manual is built.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import manualtools as mt
|
||||
|
||||
DOCS = Path(__file__).parent
|
||||
REPO = DOCS.parent
|
||||
OUTPUT = REPO / "conf.d" / "__fish_config_op_registry.fish"
|
||||
|
||||
|
||||
def collect_components() -> dict[str, list[str]]:
|
||||
"""Gather every `# COMPONENT` header across the whole repo."""
|
||||
out = mt.parse_components(REPO / "functions")
|
||||
out.update(mt.parse_components(REPO / "conf.d"))
|
||||
out.update(mt.parse_component_file(REPO / "config.fish"))
|
||||
return out
|
||||
|
||||
|
||||
def build_registry(components: dict[str, list[str]]) -> tuple[dict[str, list[str]], list[str]]:
|
||||
"""Turn {identity: [raw COMPONENT lines]} into ({"identity:site": [tags]}, warnings).
|
||||
|
||||
A site with both always/on and always/off tagged is a contradiction:
|
||||
both are stripped and a warning is emitted, but generation continues
|
||||
-- any other real tag on that same site survives. A site whose
|
||||
effective tag set is empty after stripping produces no registry entry
|
||||
at all, which __fish_config_op_enabled already treats as always/on
|
||||
(fail-open) at guard time -- see spec §4.5.
|
||||
"""
|
||||
registry: dict[str, list[str]] = {}
|
||||
warnings: list[str] = []
|
||||
for identity, raw_lines in components.items():
|
||||
by_site: dict[str, list[str]] = {}
|
||||
for site, tag in mt.parse_component_lines(raw_lines):
|
||||
by_site.setdefault(site, []).append(tag)
|
||||
for site, tags in by_site.items():
|
||||
if "always/on" in tags and "always/off" in tags:
|
||||
label = identity if not site else f"{identity}:{site}"
|
||||
warnings.append(
|
||||
f"{label}: both always/on and always/off tagged; ignoring both"
|
||||
)
|
||||
tags = [t for t in tags if t not in ("always/on", "always/off")]
|
||||
if tags:
|
||||
registry[f"{identity}:{site}"] = tags
|
||||
return registry, warnings
|
||||
|
||||
|
||||
def render(registry: dict[str, list[str]]) -> str:
|
||||
keys = sorted(registry)
|
||||
lines = [
|
||||
"# Copyright (C) 2026 Rootiest",
|
||||
"# SPDX-License-Identifier: AGPL-3.0-or-later",
|
||||
"#",
|
||||
"# GENERATED FILE --- do not edit by hand.",
|
||||
"# Regenerate with __fish_config_op_registry_rebuild after editing a",
|
||||
"# # COMPONENT header, or automatically via docs/build-manual.py.",
|
||||
"# Source: docs/generate_component_registry.py",
|
||||
"",
|
||||
]
|
||||
if not keys:
|
||||
lines.append("set -g __fish_config_op_registry_keys")
|
||||
lines.append("set -g __fish_config_op_registry_values")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
lines.append("set -g __fish_config_op_registry_keys \\")
|
||||
lines += [f" {k} \\" for k in keys[:-1]] + [f" {keys[-1]}"]
|
||||
lines.append("")
|
||||
|
||||
values = ['"' + " ".join(registry[k]) + '"' for k in keys]
|
||||
lines.append("set -g __fish_config_op_registry_values \\")
|
||||
lines += [f" {v} \\" for v in values[:-1]] + [f" {values[-1]}"]
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
components = collect_components()
|
||||
registry, warnings = build_registry(components)
|
||||
for w in warnings:
|
||||
print(f" WARN {w}", file=sys.stderr)
|
||||
OUTPUT.write_text(render(registry))
|
||||
print(f"wrote {OUTPUT} ({len(registry)} entries)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
raise SystemExit(main())
|
||||
@@ -900,6 +900,75 @@ def test_parse_component_file_single_file():
|
||||
assert got == {"config": ["site greeting-block: greeting/greeting-message"]}
|
||||
|
||||
|
||||
def test_build_registry_strips_on_off_contradiction_with_warning():
|
||||
import generate_component_registry as gcr
|
||||
|
||||
components = {"contradictory_fn": ["always/on", "always/off", "aliases/filesystem"]}
|
||||
registry, warnings = gcr.build_registry(components)
|
||||
assert registry["contradictory_fn:"] == ["aliases/filesystem"], (
|
||||
f"the non-contradictory tag should survive: {registry}"
|
||||
)
|
||||
assert len(warnings) == 1 and "contradictory_fn" in warnings[0]
|
||||
|
||||
|
||||
def test_build_registry_drops_empty_effective_tag_sets():
|
||||
import generate_component_registry as gcr
|
||||
|
||||
components = {"only_contradictory": ["always/on", "always/off"]}
|
||||
registry, warnings = gcr.build_registry(components)
|
||||
assert "only_contradictory:" not in registry, (
|
||||
"a site stripped down to nothing must produce no registry entry "
|
||||
"(fail-open: absence of an entry already means always/on at guard time)"
|
||||
)
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_build_registry_keeps_sites_independent():
|
||||
import generate_component_registry as gcr
|
||||
|
||||
components = {
|
||||
"smart_exit": [
|
||||
"site exit-plain: overrides/key-bindings",
|
||||
"site logging-guard: logging/terminal-capture",
|
||||
]
|
||||
}
|
||||
registry, warnings = gcr.build_registry(components)
|
||||
assert registry["smart_exit:exit-plain"] == ["overrides/key-bindings"]
|
||||
assert registry["smart_exit:logging-guard"] == ["logging/terminal-capture"]
|
||||
assert not warnings
|
||||
|
||||
|
||||
def test_render_registry_is_valid_fish_and_round_trips():
|
||||
"""Sourcing render()'s output must leave the two arrays in the exact
|
||||
shape __fish_config_op_registry_lookup expects -- checked via the real
|
||||
lookup helper (functions/__fish_config_op_registry_lookup.fish, Task
|
||||
2) rather than re-parsing the generated text by hand."""
|
||||
import subprocess
|
||||
|
||||
import generate_component_registry as gcr
|
||||
|
||||
registry = {
|
||||
"rm:": ["aliases/filesystem"],
|
||||
"smart_exit:exit-plain": ["overrides/key-bindings"],
|
||||
}
|
||||
text = gcr.render(registry)
|
||||
|
||||
repo = Path(__file__).parent.parent
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"fish", "-c",
|
||||
f"source {repo}/functions/__fish_config_op_registry_lookup.fish; "
|
||||
"source /dev/stdin; "
|
||||
"__fish_config_op_registry_lookup rm ''; echo status=$status",
|
||||
],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert "aliases/filesystem" in proc.stdout, f"unexpected output: {proc.stdout!r} {proc.stderr!r}"
|
||||
assert "status=0" in proc.stdout, f"lookup did not report found: {proc.stdout!r}"
|
||||
|
||||
|
||||
TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user