#!/usr/bin/env bash # Adding a settings page means editing four separate files, and missing one # fails quietly rather than loudly: # # services/SettingsRoutes.qml the taxonomy: which category the page belongs # to, and whether it is a tab inside one or a category # of its own. This is the single source of truth -- the # sidebar, the tab strip, ShellState's route # resolution and the launcher command generator all # derive from it, so a page absent here is a page that # exists nowhere # SettingsShell.qml the case that maps a leaf to a component, AND the # Component declaration itself # modules/settings/qmldir the component registration -- without it the page # is "not a type" and the whole settings window fails # to load, taking every other page with it # the .qml file itself # # ShellState no longer keeps its own allow-list: it asks SettingsRoutes to # resolve whatever id it is handed. That removed a fifth place to forget, and # this contract pins that it stays removed -- a literal list reappearing there # would silently disagree with the taxonomy again. # # Nothing at runtime cross-checks any of this. This does, statically. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" shell_file="$settings_dir/SettingsShell.qml" qmldir="$settings_dir/qmldir" shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" fail() { printf 'settings nav contract: %s\n' "$1" >&2 exit 1 } for required in "$routes" "$shell_file" "$qmldir" "$shell_state"; do [[ -r "$required" ]] || fail "cannot read $required" done # ── Reading the taxonomy ───────────────────────────────────────────────────── # A category line carries an icon; a tab line does not. A category whose tabs # are empty is a leaf itself, which is why the two shapes are read separately. category_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon:' "$routes" \ | sed -E 's/\{ page: "([a-z-]+)".*/\1/')" tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" \ | sed -E 's/\{ page: "([a-z-]+)".*/\1/')" tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \ | sed -E 's/\{ page: "([a-z-]+)".*/\1/')" [[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly' [[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly' # The leaves: every page a person can actually land on. leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')" # ── The taxonomy addresses each leaf exactly once ───────────────────────────── # ShellState.settingsPage holds a leaf id, and the sidebar highlights the # category that owns it. A leaf in two categories makes "which row lights up" # depend on iteration order, and makes the breadcrumb a coin flip. duplicate_tabs="$(sort <<<"$tab_ids" | uniq -d)" [[ -z "$duplicate_tabs" ]] \ || fail "these tab pages appear in more than one category, so the sidebar highlight and the breadcrumb become ambiguous: $(tr '\n' ' ' <<<"$duplicate_tabs")" # A category with tabs may share its id with its first tab -- "applications", # "users" and "privacy" do, and both readings land on the same category. A # category *without* tabs is a leaf, so sharing an id with a tab elsewhere # would put one page in two places. while read -r page; do [[ -n "$page" ]] || continue grep -qx "$page" <<<"$tab_ids" \ && fail "\"$page\" is a category with no tabs and also a tab of another category, so the same page id names two different places" done <<<"$tabless_ids" duplicate_categories="$(sort <<<"$category_ids" | uniq -d)" [[ -z "$duplicate_categories" ]] \ || fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")" # ── Every leaf resolves everywhere ─────────────────────────────────────────── while read -r page; do [[ -n "$page" ]] || continue # Home is the switch's default arm rather than a case, since it is also # where an unknown page falls back to. if [[ "$page" != "home" ]]; then grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \ || fail "SettingsRoutes offers \"$page\" but SettingsShell has no case for it, so opening it shows Home" fi done <<<"$leaves" # ── ShellState defers to the taxonomy instead of restating it ──────────────── grep -q 'SettingsRoutes\.resolve(' "$shell_state" \ || fail 'showSettings() does not route through SettingsRoutes.resolve, so a category id or an unknown page has no defined destination' grep -q 'const allowed = \[' "$shell_state" \ && fail 'ShellState has grown a literal allow-list again -- it will drift from SettingsRoutes, and a page missing from it silently redirects to Home' # ── Every routed component is declared and registered ──────────────────────── # The case arms name a Component id; each must have a declaration, and the type # it instantiates must appear in qmldir. while read -r component; do [[ -n "$component" ]] || continue declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \ || fail "SettingsShell routes to \"$component\" but never declares it" type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")" grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \ || fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it" [[ -r "$settings_dir/$type_name.qml" ]] \ || fail "$type_name is registered in qmldir but $type_name.qml does not exist" done < <({ grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/' grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/' } | sort -u) # ── Every page file is reachable ───────────────────────────────────────────── # A page nobody can navigate to is dead code that still has to compile. The # scaffold SettingsPage.qml is the one file here that is a base class rather # than a page. while read -r page_file; do type_name="$(basename "$page_file" .qml)" [[ "$type_name" == "SettingsPage" ]] && continue grep -qE "; $type_name \{\} \}" "$shell_file" \ || fail "$type_name.qml exists but nothing in SettingsShell instantiates it" done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml') printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \ "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")"