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"]}
|
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_")]
|
TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user