Files
Panama/config/dot/quickshell/scripts/panama-settings-docs
T

300 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
Where each group of settings lives comes from SettingsSearch.qml's routing and
SettingsRoutes.qml's taxonomy, so a page renamed or moved into another category
is renamed here too rather than in a list somebody has to remember.
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"
ROUTES = ROOT / "config/dot/quickshell/services/SettingsRoutes.qml"
OUTPUT = ROOT / "docs/settings.md"
CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
HIDDEN = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*category:\s*"([a-z-]+)"\s*\}')
class SchemaError(RuntimeError):
"""The schema stopped looking the way this reader expects."""
def read_titles():
"""Leaf page id -> the name a person sees, from SettingsRoutes.
The sidebar is fourteen categories of tabs rather than a flat list, so a page
is named the way a hit names it in search: "System Storage" for a tab,
"Displays" for a category that is a page by itself.
"""
text = ROUTES.read_text()
block = re.search(r"readonly property var categories: \[(.*?)\n \]", text, re.S)
if not block:
raise SchemaError("could not find the categories array in SettingsRoutes.qml")
titles = {}
labels = {}
read = 0
for page, label, tabs in CATEGORY.findall(block.group(1)):
found = TAB.findall(tabs)
labels[page] = label
read += 1 + len(found)
if found:
titles.update({tab: f"{label} {tab_label}" for tab, tab_label in found})
else:
titles[page] = label
# A category this reader stopped recognising would take its pages' names
# with it, and the group that routed to one would then be documented as
# living nowhere. Every `page:` in the array has to be accounted for.
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
if not titles or read != declared:
raise SchemaError(
f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
# Leaves that are routable but draw no tab -- the manual -- are declared
# outside the categories array, because the reader above requires each
# category to end `tabs: [...] }` and accounts for every `page:` inside it.
# They are still pages somebody lands on, so they are still named here.
hidden = re.search(r"readonly property var hiddenLeaves: \[(.*?)\n \]", text, re.S)
if hidden:
found = HIDDEN.findall(hidden.group(1))
if len(found) != len(re.findall(r'\bpage:\s*"', hidden.group(1))):
raise SchemaError(
"the hiddenLeaves array no longer looks the way this reader expects")
for page, label, category in found:
titles[page] = f"{labels.get(category, category)} {label}"
return titles
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 = []
# An entry is only found when `key:` is the first thing inside its brace, so
# a comment written INSIDE the literal rather than above it makes the whole
# setting invisible here -- silently, and the staleness contract cannot see
# it either, because regenerating reproduces the same omission. Three
# settings were undocumented this way before anyone noticed, one of them for
# weeks. Counting what was declared against what was parsed is what turns
# that from silence into an error.
declared = {m.group(1) for m in re.finditer(r'\bkey:\s*"([^"]+)"', text)}
parsed = {m.group(1) for m in re.finditer(r'\{\s*\n\s*key:\s*"([^"]+)"', text)}
invisible = sorted(declared - parsed)
if invisible:
raise SchemaError(
"these settings are declared but cannot be read, which means they would "
"be silently missing from the reference: " + ", ".join(invisible)
+ ". Move the comment above the entry's opening brace.")
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())
def default_value():
"""The default, or None when the literal is a block rather than a value.
A "json" setting's `def:` opens an array or an object that runs for
twenty lines, and the scalar reader above captures only the bracket
that opened it. A Default column reading "[" documents nothing and
looks like a parsing bug, which is what it was mistaken for. None
renders as an em dash instead, and the schema stays the place to read
the shape.
"""
value = scalar("def")
return None if value in ("[", "{") else value
entry = {
"key": name,
"type": scalar("type"),
"default": default_value(),
"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, titles):
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)
# A routed page with no name used to fall back to the raw page id, so
# the group "gaming" documented itself as "Found on **gaming**" while
# every other group named a real page. The staleness contract could not
# see it: regenerating reproduced the same wrong file, so the copy was
# current and wrong at once. Refusing to render is what makes a group
# routed at a page that no longer exists impossible to miss.
if page is not None and page not in titles:
raise SchemaError(
f"group '{group}' routes to page '{page}', which is not a leaf page "
"in SettingsRoutes.qml; route it at one rather than letting the id "
"be printed as a name"
)
title = 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(), read_titles())
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())