Every other form of documentation here has drifted at least once today: search routing that pointed at a page not containing the setting, a contract that pinned the bug the same commit fixed, and a comment in shell.qml that failed to stop me making the exact mistake it described. Prose describing 127 settings would drift the day after it was written. So docs/settings.md is generated, and a contract fails the moment the committed copy stops matching the schema. The document cannot be wrong for longer than it takes to run the suite. It reads the schema by parsing rather than importing, since there is no QML interpreter here and requiring a compositor to build documentation would be worse. That parser is the risk, so it FAILS LOUDLY: if it stops recognising the file it exits non-zero with the reason and writes nothing, because a partial reference is worse than a stale one -- stale is caught by --check, partial reads as complete. Verified: with the entry pattern broken it reports "only 0 entries parsed" and leaves the committed file untouched. The contract also proves --check actually compares content, by appending a line and confirming it fails, rather than trusting a command that returns success to mean anything. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
215 lines
8.1 KiB
Python
Executable File
215 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
|
||
"""Generate the settings reference from PreferenceSchema.qml.
|
||
|
||
Every other form of documentation in this repository has drifted at least once:
|
||
routing that pointed at a page not containing the setting, contracts that pinned
|
||
the bug they were meant to prevent, and a comment that failed to stop the person
|
||
who read it making the exact mistake it warned about. Prose describing 137
|
||
settings would drift the day after it was written.
|
||
|
||
So this is generated, and a contract fails when the committed copy no longer
|
||
matches what the schema says. The document cannot be wrong for longer than it
|
||
takes to run the suite.
|
||
|
||
Usage:
|
||
panama-settings-docs write docs/settings.md
|
||
panama-settings-docs --check exit 1 if the committed copy is stale
|
||
panama-settings-docs --stdout print without writing
|
||
|
||
Parsing rather than importing: the schema is QML, there is no QML interpreter
|
||
here, and a regex reader that FAILS LOUDLY when it stops recognising the file is
|
||
better than a dependency on a shell that has to start a compositor.
|
||
"""
|
||
|
||
import argparse
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
|
||
# scripts/ -> quickshell/ -> dot/ -> config/ -> repo root. resolve() first, so
|
||
# this still works when invoked through the ~/.config/quickshell symlink.
|
||
ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||
SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
|
||
SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
|
||
OUTPUT = ROOT / "docs/settings.md"
|
||
|
||
# Page id -> the name a person sees in the sidebar.
|
||
PAGE_TITLES = {
|
||
"home": "Home", "appearance": "Appearance", "displays": "Displays",
|
||
"connectivity": "Network & Devices", "home-phone": "Home & Phone",
|
||
"desktop": "Desktop & Dock", "sound": "Sound",
|
||
"notifications": "Notifications & Focus",
|
||
"screen-intelligence": "Screen Intelligence", "shortcuts": "Keyboard",
|
||
"mouse": "Mouse & Touchpad", "privacy": "Privacy & Security",
|
||
"region": "Region & Language", "accounts": "Online Accounts",
|
||
"accessibility": "Accessibility", "power": "Power & Lock",
|
||
"datetime": "Date & Time", "applications": "Applications",
|
||
"services": "System Health", "about": "About",
|
||
}
|
||
|
||
|
||
class SchemaError(RuntimeError):
|
||
"""The schema stopped looking the way this reader expects."""
|
||
|
||
|
||
def read_routes():
|
||
"""group -> page id, from SettingsSearch."""
|
||
text = SEARCH.read_text()
|
||
block = re.search(r"groupPages:\s*\(\{(.*?)\}\)", text, re.S)
|
||
if not block:
|
||
raise SchemaError("could not find groupPages in SettingsSearch.qml")
|
||
routes = dict(re.findall(r'"([a-zA-Z]+)"\s*:\s*"([a-z-]+)"', block.group(1)))
|
||
if not routes:
|
||
raise SchemaError("groupPages matched but contained no routes")
|
||
return routes
|
||
|
||
|
||
def read_entries():
|
||
"""Every schema entry, as dicts, in declaration order."""
|
||
text = SCHEMA.read_text()
|
||
|
||
# Each entry begins at `key:` and ends at the closing brace of its block.
|
||
# Nested braces (options, hypr) are skipped by counting depth.
|
||
entries = []
|
||
for match in re.finditer(r'\{\s*\n\s*key:\s*"([^"]+)"', text):
|
||
name = match.group(1)
|
||
start = match.start()
|
||
depth = 0
|
||
end = None
|
||
for index in range(start, len(text)):
|
||
if text[index] == "{":
|
||
depth += 1
|
||
elif text[index] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
end = index
|
||
break
|
||
if end is None:
|
||
raise SchemaError(f"entry {name!r} is not brace-balanced")
|
||
body = text[start:end]
|
||
|
||
def scalar(field):
|
||
found = re.search(rf'\b{field}:\s*("([^"]*)"|[^,\n]+)', body)
|
||
if not found:
|
||
return None
|
||
return (found.group(2) if found.group(2) is not None
|
||
else found.group(1).strip())
|
||
|
||
entry = {
|
||
"key": name,
|
||
"type": scalar("type"),
|
||
"default": scalar("def"),
|
||
"group": scalar("group"),
|
||
"label": scalar("label"),
|
||
"detail": scalar("detail"),
|
||
"unit": scalar("unit"),
|
||
"min": scalar("min"),
|
||
"max": scalar("max"),
|
||
"internal": scalar("internal") == "true",
|
||
"option": scalar("option"),
|
||
"options": re.findall(r'\{\s*value:\s*("?[^",]+)"?,\s*label:\s*"([^"]+)"', body),
|
||
}
|
||
if not entry["type"] or not entry["group"]:
|
||
raise SchemaError(f"entry {name!r} is missing a type or group")
|
||
entries.append(entry)
|
||
|
||
if len(entries) < 50:
|
||
raise SchemaError(
|
||
f"only {len(entries)} entries parsed; the schema has far more, so "
|
||
"this reader no longer recognises the file"
|
||
)
|
||
return entries
|
||
|
||
|
||
def render(entries, routes):
|
||
lines = [
|
||
"# Settings reference",
|
||
"",
|
||
"**Generated from `config/dot/quickshell/config/PreferenceSchema.qml`.**",
|
||
"Do not edit this file. Run `quickshell/scripts/panama-settings-docs`",
|
||
"after changing the schema; a contract fails when this copy is stale.",
|
||
"",
|
||
f"{len([e for e in entries if not e['internal']])} settings across "
|
||
f"{len({e['group'] for e in entries if not e['internal']})} groups. "
|
||
f"{len([e for e in entries if e['option']])} of them are applied to the "
|
||
"compositor and confirmed by reading the value back.",
|
||
"",
|
||
]
|
||
|
||
by_group = {}
|
||
for entry in entries:
|
||
by_group.setdefault(entry["group"], []).append(entry)
|
||
|
||
for group in sorted(by_group):
|
||
visible = [e for e in by_group[group] if not e["internal"]]
|
||
if not visible:
|
||
continue
|
||
page = routes.get(group)
|
||
title = PAGE_TITLES.get(page, page or "—")
|
||
lines += [f"## {group}", "", f"Found on **{title}**.", ""]
|
||
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
|
||
for entry in visible:
|
||
default = entry["default"] or "—"
|
||
if entry["unit"]:
|
||
default = f"{default} {entry['unit']}"
|
||
if entry["options"]:
|
||
choices = ", ".join(label for _value, label in entry["options"])
|
||
detail = f"{entry['detail'] or ''} Choices: {choices}."
|
||
else:
|
||
detail = entry["detail"] or ""
|
||
if entry["min"] is not None and entry["max"] is not None:
|
||
# Schema details are written without trailing punctuation, so
|
||
# one is added before appending a second sentence.
|
||
if detail and not detail.rstrip().endswith((".", "!", "?")):
|
||
detail = detail.rstrip() + "."
|
||
detail += f" Range {entry['min']}–{entry['max']}."
|
||
label = entry["label"] or entry["key"]
|
||
compositor = f" `{entry['option']}`" if entry["option"] else ""
|
||
lines.append(
|
||
f"| **{label}**<br>`{entry['key']}`{compositor} | {default} | "
|
||
f"{detail.strip()} |"
|
||
)
|
||
lines.append("")
|
||
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--check", action="store_true")
|
||
parser.add_argument("--stdout", action="store_true")
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
rendered = render(read_entries(), read_routes())
|
||
except SchemaError as error:
|
||
# Loudly, and without writing. A partial reference is worse than a stale
|
||
# one: stale is caught by --check, partial reads as complete.
|
||
print(f"panama-settings-docs: {error}", file=sys.stderr)
|
||
return 2
|
||
|
||
if args.stdout:
|
||
print(rendered, end="")
|
||
return 0
|
||
|
||
if args.check:
|
||
if not OUTPUT.exists():
|
||
print("panama-settings-docs: docs/settings.md has never been generated",
|
||
file=sys.stderr)
|
||
return 1
|
||
if OUTPUT.read_text() != rendered:
|
||
print("panama-settings-docs: docs/settings.md is stale; re-run this "
|
||
"script and commit the result", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||
OUTPUT.write_text(rendered)
|
||
print(f"wrote {OUTPUT.relative_to(ROOT)}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|