diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 8134284..fb12975 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -11,6 +11,8 @@ on: - "docs/verify-manual.py" - "docs/site/**" - "functions/**" + - "conf.d/**" + - "config.fish" workflow_dispatch: jobs: @@ -76,7 +78,7 @@ jobs: run: | git config user.name "Gitea Actions" git config user.email "actions@gitea" - git add docs/fish-config.md docs/fish-config.1 + git add docs/fish-config.md docs/fish-config.1 conf.d/__fish_config_op_registry.fish git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "chore(docs): regenerate manual and man page" + git commit -m "chore(docs): regenerate manual, man page, and component registry" git push diff --git a/README.md b/README.md index 0cd1165..11e074c 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,14 @@ set -Ue __fish_config_op_greeting Command shadows react immediately; bindings, prompt, and abbreviations take effect in new shells. With aliases disabled, `rm` deletes permanently again instead of trashing. See `help config opinionated` for the full component list. +Each category further sub-divides into two to six sub-categories with +their own `__fish_config_op__` toggles (e.g. +`__fish_config_op_aliases_filesystem`), following the exact same +truthy/falsy/unset cascade one level deeper. Run `config-settings` and +press Enter on a category row to browse and toggle its sub-categories, or +see the [Components Reference](https://fish.rootiest.fyi/08-components-reference/) +for the full sub-category list per category. + --- ## Attribution diff --git a/conf.d/__fish_config_op_registry.fish b/conf.d/__fish_config_op_registry.fish new file mode 100644 index 0000000..1d64de1 --- /dev/null +++ b/conf.d/__fish_config_op_registry.fish @@ -0,0 +1,146 @@ +# 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 +# +# This file must be sourced before any other conf.d/*.fish file that +# calls the opinionated guard. That currently holds only because fish's +# glob-based conf.d loading happens to sort this filename first +# alphabetically among the guard-calling files -- do not rename it +# without preserving that ordering. + +set -g __fish_config_op_registry_keys \ + "__auto_source_fallback_venv:" \ + "__fish_config_sync_logging:" \ + "__fish_user_dots_link:" \ + "_zellij_dump_log:" \ + "abbr:abbr-integrations" \ + "abbr:abbr-overrides" \ + "agy:" \ + "auto-pull:" \ + "autopair:" \ + "bash:" \ + "bash_expands:" \ + "cat:" \ + "claude:" \ + "config:cachyos-strip-aliases" \ + "config:cachyos-strip-overrides" \ + "config:cachyos-tricks" \ + "config:cdpath" \ + "config:exit-wiring" \ + "config:greeting-stamp" \ + "config:pager-editor-gpg" \ + "config:path-setup" \ + "config:vi-mode" \ + "done:" \ + "du:" \ + "edit:" \ + "first_run:first-run-bootstrap" \ + "first_run:first-run-greeting" \ + "fish_right_prompt:" \ + "help:" \ + "hist:" \ + "key_bindings:" \ + "kitty-logging:" \ + "kitty-watcher-reminder:" \ + "less:" \ + "logs:" \ + "ls:" \ + "mkdir:" \ + "mv:" \ + "paru-wrapper:paru-autoexec" \ + "paru-wrapper:paru-logging" \ + "ping:" \ + "puffer:" \ + "rg:" \ + "rm:" \ + "smart_exit:exit-plain" \ + "smart_exit:logging-guard" \ + "split:" \ + "spwin:" \ + "ssh:" \ + "starship:" \ + "tab:" \ + "theme:" \ + "tmux-logging:" \ + "top:" \ + "tricks:aliases-tricks" \ + "tricks:tricks-bang" \ + "tricks:tricks-manpager" \ + "upgrade:" \ + "wakatime:wakatime-autoexec" \ + "wakatime:wakatime-hook" \ + "yay-wrapper:yay-autoexec" \ + "yay-wrapper:yay-logging" \ + "yt-dlp:" \ + "zoxide:" + +set -g __fish_config_op_registry_values \ + "autoexec/venv" \ + "logging/terminal-capture" \ + "autoexec/sync" \ + "logging/multiplexer-capture" \ + "integrations/terminal-abbrs" \ + "overrides/key-bindings" \ + "aliases/dev-tools" \ + "autoexec/sync" \ + "overrides/key-bindings" \ + "aliases/shell-tools" \ + "overrides/key-bindings" \ + "aliases/filesystem" \ + "aliases/dev-tools" \ + "aliases/filesystem" \ + "overrides/key-bindings" \ + "overrides/environment" \ + "overrides/environment" \ + "overrides/key-bindings" \ + "greeting/greeting-message" \ + "overrides/environment" \ + "overrides/environment" \ + "overrides/key-bindings" \ + "integrations/notifications" \ + "aliases/filesystem" \ + "aliases/dev-tools" \ + "autoexec/plugin-management" \ + "greeting/first-run" \ + "overrides/prompt" \ + "aliases/shell-tools" \ + "integrations/history-logs" \ + "overrides/key-bindings" \ + "logging/terminal-capture" \ + "logging/terminal-capture" \ + "aliases/shell-tools" \ + "integrations/history-logs" \ + "aliases/filesystem" \ + "aliases/filesystem" \ + "aliases/filesystem" \ + "autoexec/pkg-wrappers" \ + "logging/pkg-logs" \ + "aliases/network" \ + "overrides/key-bindings" \ + "aliases/search" \ + "aliases/filesystem" \ + "overrides/key-bindings" \ + "logging/terminal-capture" \ + "integrations/window-mgmt" \ + "integrations/window-mgmt" \ + "aliases/network" \ + "overrides/prompt" \ + "integrations/window-mgmt" \ + "overrides/prompt" \ + "logging/multiplexer-capture" \ + "aliases/monitor" \ + "aliases/filesystem" \ + "overrides/key-bindings" \ + "overrides/environment" \ + "integrations/pkg-upgrade" \ + "autoexec/telemetry" \ + "integrations/notifications" \ + "autoexec/pkg-wrappers" \ + "logging/pkg-logs" \ + "aliases/network" \ + "aliases/filesystem" + diff --git a/conf.d/abbr.fish b/conf.d/abbr.fish index f7ef839..5771e32 100644 --- a/conf.d/abbr.fish +++ b/conf.d/abbr.fish @@ -8,6 +8,10 @@ # This file contains all the abbreviations for the terminal. # It is sourced by Fish on startup. +# COMPONENT +# site abbr-integrations: integrations/terminal-abbrs +# site abbr-overrides: overrides/key-bindings + # Neovim # @category Editors # @desc nvim @@ -155,7 +159,7 @@ abbr -a ag. agy . # @desc exit abbr -a /exit exit # Window-management abbreviations are opinionated (C4 integrations) -if __fish_config_op_enabled __fish_config_op_integrations +if __fish_config_op_enabled (status basename) abbr-integrations if test "$TERM" = xterm-kitty # @category Terminal Windows, Tabs, and Panes # @desc Close current pane/window @@ -208,7 +212,7 @@ abbr -a speedtest-fast fast-cli # Kitty/WezTerm window-management abbreviations are opinionated (C4 # integrations): they assume an active Kitty or WezTerm session. -if __fish_config_op_enabled __fish_config_op_integrations +if __fish_config_op_enabled (status basename) abbr-integrations # Window Creation (OS Windows) if test "$TERM" = xterm-kitty # @category Terminal Windows, Tabs, and Panes @@ -666,7 +670,7 @@ abbr -a url-open open-url ### History Expansions and Substitutions ### # Bash-style history expansion is opinionated (C3 overrides), gated atomically # with conf.d/tricks.fish, conf.d/puffer.fish, and functions/expand_*.fish. -if __fish_config_op_enabled __fish_config_op_overrides +if __fish_config_op_enabled (status basename) abbr-overrides # @category History Expansion # @name !^ # @desc Expand to the first argument of the previous command diff --git a/conf.d/auto-pull.fish b/conf.d/auto-pull.fish index 41fa0a7..852559a 100644 --- a/conf.d/auto-pull.fish +++ b/conf.d/auto-pull.fish @@ -14,8 +14,11 @@ # Manage the registry with: auto-pull add / remove / list / status # C2 guard: when auto-execution is disabled, do not register the handler. -__fish_config_op_enabled __fish_config_op_autoexec; or exit +__fish_config_op_enabled (status basename); or exit +# COMPONENT +# autoexec/sync +# # SYNOPSIS # __auto_pull_on_pwd (event handler, --on-variable PWD) # diff --git a/conf.d/autopair.fish b/conf.d/autopair.fish index 571457f..66741d3 100644 --- a/conf.d/autopair.fish +++ b/conf.d/autopair.fish @@ -1,8 +1,11 @@ status is-interactive || exit +# COMPONENT +# overrides/key-bindings + # Local modification: opinionated guard (AGENTS.md Task #3). Bracket # auto-pairing intercepts single-character input, classified as C3 overrides. -__fish_config_op_enabled __fish_config_op_overrides || exit +__fish_config_op_enabled (status basename) || exit set --global autopair_left "(" "[" "{" '"' "'" set --global autopair_right ")" "]" "}" '"' "'" diff --git a/conf.d/bash_expands.fish b/conf.d/bash_expands.fish index 5c6a9d3..d81dc91 100644 --- a/conf.d/bash_expands.fish +++ b/conf.d/bash_expands.fish @@ -1,13 +1,16 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# COMPONENT +# overrides/key-bindings + # Provides bash-style history expansion functions for abbreviations. # These functions are gated by the C3 overrides switch. # Execute expand_bang_all function expand_bang_all --description 'Execute expand_bang_all' # Opinionated guard (C3): no expansion when overrides are disabled. - __fish_config_op_enabled __fish_config_op_overrides; or return 1 + __fish_config_op_enabled (status basename); or return 1 set -l token $argv[1] if test -z "$token"; set token (commandline -t); end @@ -23,7 +26,7 @@ end # Execute expand_bang_caret function expand_bang_caret --description 'Execute expand_bang_caret' # Opinionated guard (C3): no expansion when overrides are disabled. - __fish_config_op_enabled __fish_config_op_overrides; or return 1 + __fish_config_op_enabled (status basename); or return 1 # Split the last history item into a list set -l tokens (string split -n ' ' -- $history[1]) @@ -36,7 +39,7 @@ end # Execute expand_bang_minus_n function expand_bang_minus_n --description 'Execute expand_bang_minus_n' # Opinionated guard (C3): no expansion when overrides are disabled. - __fish_config_op_enabled __fish_config_op_overrides; or return 1 + __fish_config_op_enabled (status basename); or return 1 set -l token $argv[1] if test -z "$token"; set token (commandline -t); end @@ -58,7 +61,7 @@ end # Execute expand_bang_search function expand_bang_search --description 'Execute expand_bang_search' # Opinionated guard (C3): no expansion when overrides are disabled. - __fish_config_op_enabled __fish_config_op_overrides; or return 1 + __fish_config_op_enabled (status basename); or return 1 set -l token $argv[1] if test -z "$token" @@ -84,7 +87,7 @@ end # Execute expand_bang_string function expand_bang_string --description 'Execute expand_bang_string' # Opinionated guard (C3): no expansion when overrides are disabled. - __fish_config_op_enabled __fish_config_op_overrides; or return 1 + __fish_config_op_enabled (status basename); or return 1 # Fish 4.x passes the matched token as argv[1] set -l token $argv[1] @@ -112,7 +115,7 @@ end # Execute expand_typo_sub function expand_typo_sub --description 'Execute expand_typo_sub' # Opinionated guard (C3): no expansion when overrides are disabled. - __fish_config_op_enabled __fish_config_op_overrides; or return 1 + __fish_config_op_enabled (status basename); or return 1 # In newer Fish, the matched token is often passed as $argv[1] # if the abbr is set up correctly. We'll fallback to commandline just in case. diff --git a/conf.d/done.fish b/conf.d/done.fish index bb108d0..95a0e3b 100644 --- a/conf.d/done.fish +++ b/conf.d/done.fish @@ -19,6 +19,9 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +# +# COMPONENT +# integrations/notifications if not status is-interactive exit @@ -26,7 +29,7 @@ end # Local modification: opinionated guard (AGENTS.md Task #3). Desktop # notifications assume a graphical session, classified as C4 integrations. -__fish_config_op_enabled __fish_config_op_integrations; or exit +__fish_config_op_enabled (status basename); or exit set -g __done_version 1.19.1 diff --git a/conf.d/first_run.fish b/conf.d/first_run.fish index d098456..0939edf 100644 --- a/conf.d/first_run.fish +++ b/conf.d/first_run.fish @@ -7,6 +7,10 @@ # # Runs exactly once on the first interactive fish session after install. # To reset for testing, run: set -Ue __fish_config_first_run_complete +# +# COMPONENT +# site first-run-greeting: greeting/first-run +# site first-run-bootstrap: autoexec/plugin-management # Exit early in non-interactive shells (scripts, completions, subshells) if not status is-interactive @@ -36,7 +40,7 @@ end # Printing a first-run welcome banner is opinionated (C6 greeting). The # first-run state variable is already set unconditionally above, so # disabling the greeting never re-triggers this file. -if __fish_config_op_enabled __fish_config_op_greeting +if __fish_config_op_enabled (status basename) first-run-greeting echo "" echo " Welcome to your fish shell configuration!" echo " Run 'help config' for offline documentation." @@ -48,7 +52,7 @@ end # Startup side-effects below (Fisher curl, fisher update, theme apply) are # opinionated (C2 auto-execution). The first-run state variable is already # set above either way, so disabling auto-exec never re-triggers this file. -if not __fish_config_op_enabled __fish_config_op_autoexec +if not __fish_config_op_enabled (status basename) first-run-bootstrap return end diff --git a/conf.d/help.fish b/conf.d/help.fish index ecca847..0e7e3e5 100644 --- a/conf.d/help.fish +++ b/conf.d/help.fish @@ -5,6 +5,9 @@ # │ help config wrapper │ # ╰──────────────────────────────────────────────────────────╯ # +# COMPONENT +# aliases/shell-tools +# # SYNOPSIS # help [topic] [sub-topic...] # help config [section] [-w|--html] [-m|--man] [-h|--help] @@ -50,7 +53,7 @@ end # --- Wrapper Definition --- function help --wraps help --description "Custom wrapper to intercept 'help config'" # Opinionated guard (C1): fall back to the native fish help when disabled. - if not __fish_config_op_enabled __fish_config_op_aliases + if not __fish_config_op_enabled (status current-function) __original_help $argv return $status end diff --git a/conf.d/key_bindings.fish b/conf.d/key_bindings.fish index 21a2855..1f9793f 100644 --- a/conf.d/key_bindings.fish +++ b/conf.d/key_bindings.fish @@ -8,6 +8,9 @@ # This file defines custom key bindings for the Fish shell. # It is sourced by Fish on startup. +# COMPONENT +# overrides/key-bindings + # ────────────────── Bind Prewious Path Head to Ctrl+G ───────────────── # Bindings to insert the previous path head into the command line # Behaves like `!$:h` does in bash @@ -48,7 +51,11 @@ function fish_user_key_bindings # Custom key chords are opinionated (C3 overrides); skip them entirely # when overrides are disabled so stock bindings remain untouched. - __fish_config_op_enabled __fish_config_op_overrides; or return + # NOTE: (status basename), not (status current-function) -- this guard + # lives inside fish's own reserved fish_user_key_bindings function, whose + # name is not this file's identity; the registry key is this file's + # bare basename, key_bindings. + __fish_config_op_enabled (status basename); or return # ───────────────────────────── Set Bindings ───────────────────────────── # diff --git a/conf.d/kitty-watcher-reminder.fish b/conf.d/kitty-watcher-reminder.fish index ad8e37c..d8bcb71 100644 --- a/conf.d/kitty-watcher-reminder.fish +++ b/conf.d/kitty-watcher-reminder.fish @@ -1,5 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later + +# COMPONENT +# logging/terminal-capture + # # C5 — Logging & Capture: a non-blocking, per-session reminder shown inside Kitty # when the fish-config scrollback watcher is not yet set up. It never blocks the @@ -10,7 +14,7 @@ status is-interactive; or exit type -q kitty; or exit set -q KITTY_WINDOW_ID; or exit -__fish_config_op_enabled __fish_config_op_logging; or exit +__fish_config_op_enabled (status basename); or exit __fish_variable_check __fish_config_kitty_watcher_dismissed; and exit __kitty_logging_has_watcher; and exit diff --git a/conf.d/paru-wrapper.fish b/conf.d/paru-wrapper.fish index 13e3159..bef1e54 100644 --- a/conf.d/paru-wrapper.fish +++ b/conf.d/paru-wrapper.fish @@ -6,12 +6,16 @@ # bars are preserved, renders the captured animation to a clean static log # (via scripts/clean_progress_log.py), and prunes old logs. +# COMPONENT +# site paru-autoexec: autoexec/pkg-wrappers +# site paru-logging: logging/pkg-logs + # Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec). # Wrapper generation is also gated by C5 (Logging & Capture). -__fish_config_op_enabled __fish_config_op_autoexec; or return +__fish_config_op_enabled (status basename) paru-autoexec; or return # C5 — Logging & Capture: remove generated wrapper and skip when logging is off -if not __fish_config_op_enabled __fish_config_op_logging +if not __fish_config_op_enabled (status basename) paru-logging if test -f "$HOME/.local/bin/paru" and grep -q "# paru-wrapper-version:" "$HOME/.local/bin/paru" 2>/dev/null rm -f "$HOME/.local/bin/paru" diff --git a/conf.d/puffer.fish b/conf.d/puffer.fish index 35ac61c..fbe7ad0 100644 --- a/conf.d/puffer.fish +++ b/conf.d/puffer.fish @@ -1,9 +1,12 @@ status is-interactive || exit +# COMPONENT +# overrides/key-bindings + # Local modification: opinionated guard (AGENTS.md Task #3). Puffer's key # intercepts are part of the bang-bang system, gated atomically under C3 # overrides with conf.d/tricks.fish, conf.d/abbr.fish, and expand_*.fish. -__fish_config_op_enabled __fish_config_op_overrides || exit +__fish_config_op_enabled (status basename) || exit function _puffer_fish_key_bindings --on-variable fish_key_bindings set -l modes diff --git a/conf.d/starship.fish b/conf.d/starship.fish index a7a429a..fc9673e 100644 --- a/conf.d/starship.fish +++ b/conf.d/starship.fish @@ -1,12 +1,16 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later + +# COMPONENT +# overrides/prompt + # # Defines fish_prompt only when starship is installed. # Without starship, fish's built-in prompt already emits OSC 133;A # on the prompt line itself, so no wrapper is needed. # Replacing the prompt is opinionated (C3 overrides) -__fish_config_op_enabled __fish_config_op_overrides; or return +__fish_config_op_enabled (status basename); or return type -q starship; or return diff --git a/conf.d/theme.fish b/conf.d/theme.fish index 1cd1f8a..5944728 100644 --- a/conf.d/theme.fish +++ b/conf.d/theme.fish @@ -1,5 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later + +# COMPONENT +# overrides/prompt + # # ╭──────────────────────────────────────────────────────────╮ # │ Fish Theme │ @@ -9,7 +13,7 @@ # Forcing theme colors and $FZF_DEFAULT_OPTS is opinionated (C3 overrides). # The FZF variable is universal, so clean up our Catppuccin value if it # lingers from a session where overrides were still enabled. -if not __fish_config_op_enabled __fish_config_op_overrides +if not __fish_config_op_enabled (status basename) if set -q FZF_DEFAULT_OPTS; and string match -q '*#1E1E2E*' -- "$FZF_DEFAULT_OPTS" set --erase FZF_DEFAULT_OPTS end diff --git a/conf.d/tmux-logging.fish b/conf.d/tmux-logging.fish index cc416b4..c1857fc 100644 --- a/conf.d/tmux-logging.fish +++ b/conf.d/tmux-logging.fish @@ -1,12 +1,16 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later + +# COMPONENT +# logging/multiplexer-capture + # # C5 — Logging & Capture: starts a pipe-pane log for the current tmux pane # when fish launches inside a tmux session. Each fish shell gets its own # timestamped log file in SCROLLBACK_HISTORY_DIR (default: ~/.terminal_history). # Naming: tmux_-w-p_YYYY-MM-DD_HH-MM-SS.log -__fish_config_op_enabled __fish_config_op_logging; or exit +__fish_config_op_enabled (status basename); or exit status is-interactive; or exit type -q tmux; or exit set -q TMUX; or exit diff --git a/conf.d/tricks.fish b/conf.d/tricks.fish index 4662a49..a3a5474 100644 --- a/conf.d/tricks.fish +++ b/conf.d/tricks.fish @@ -7,6 +7,11 @@ # │ system aliases, and history/backup utilities │ # ╰──────────────────────────────────────────────────────────╯ +# COMPONENT +# site aliases-tricks: aliases/filesystem +# site tricks-manpager: overrides/environment +# site tricks-bang: overrides/key-bindings + ## Environment setup # Apply .profile: use this to put fish compatible .profile stuff in if test -f ~/.fish_profile @@ -24,7 +29,7 @@ end # Format man pages using bat (only if bat is installed) # Overriding $MANPAGER is opinionated (C3 overrides) -if type -q bat; and __fish_config_op_enabled __fish_config_op_overrides +if type -q bat; and __fish_config_op_enabled (status basename) tricks-manpager set -gx MANROFFOPT -c set -gx MANPAGER "sh -c 'col -bx | bat -l man -p'" end @@ -37,7 +42,7 @@ set -gx __done_notification_urgency_level low # Functions needed for !! and !$ https://github.com/oh-my-fish/plugin-bang-bang # The bang-bang system is opinionated (C3 overrides) and is gated atomically # here, in conf.d/abbr.fish, conf.d/puffer.fish, and functions/expand_*.fish. -if __fish_config_op_enabled __fish_config_op_overrides +if __fish_config_op_enabled (status basename) tricks-bang function __history_previous_command switch (commandline -t) case "!" @@ -83,7 +88,7 @@ end # Fish command history override to show timestamps # Shadowing the history command is opinionated (C1 aliasing); when disabled, # the function is never defined and fish's stock history behavior applies. -if __fish_config_op_enabled __fish_config_op_aliases +if __fish_config_op_enabled (status basename) aliases-tricks function history builtin history --show-time='%F %T ' end @@ -123,7 +128,7 @@ alias .....='cd ../../../..' alias ......='cd ../../../../..' # Silent flag injection into POSIX tools is opinionated (C1 aliasing) -if __fish_config_op_enabled __fish_config_op_aliases +if __fish_config_op_enabled (status basename) aliases-tricks # Tools & Core command color overrides # @category Shell Aliases # @desc dir --color=auto diff --git a/conf.d/wakatime.fish b/conf.d/wakatime.fish index 91c88f4..db97a43 100644 --- a/conf.d/wakatime.fish +++ b/conf.d/wakatime.fish @@ -5,11 +5,15 @@ # see: https://github.com/ik11235/wakatime.fish ### +# COMPONENT +# site wakatime-autoexec: autoexec/telemetry +# site wakatime-hook: integrations/notifications + # Local modification: opinionated guard (AGENTS.md Task #3). WakaTime # reporting is classified under both C2 auto-execution and C4 integrations; # disabling either category skips registering the hook. -__fish_config_op_enabled __fish_config_op_autoexec; or exit -__fish_config_op_enabled __fish_config_op_integrations; or exit +__fish_config_op_enabled (status basename) wakatime-autoexec; or exit +__fish_config_op_enabled (status basename) wakatime-hook; or exit function __register_wakatime_fish_before_exec -e fish_postexec if set -q FISH_WAKATIME_DISABLED diff --git a/conf.d/yay-wrapper.fish b/conf.d/yay-wrapper.fish index 203f306..53da589 100644 --- a/conf.d/yay-wrapper.fish +++ b/conf.d/yay-wrapper.fish @@ -6,12 +6,16 @@ # bars are preserved, renders the captured animation to a clean static log # (via scripts/clean_progress_log.py), and prunes old logs. +# COMPONENT +# site yay-autoexec: autoexec/pkg-wrappers +# site yay-logging: logging/pkg-logs + # Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec). # Wrapper generation is also gated by C5 (Logging & Capture). -__fish_config_op_enabled __fish_config_op_autoexec; or return +__fish_config_op_enabled (status basename) yay-autoexec; or return # C5 — Logging & Capture: remove generated wrapper and skip when logging is off -if not __fish_config_op_enabled __fish_config_op_logging +if not __fish_config_op_enabled (status basename) yay-logging if test -f "$HOME/.local/bin/yay" and grep -q "# yay-wrapper-version:" "$HOME/.local/bin/yay" 2>/dev/null rm -f "$HOME/.local/bin/yay" diff --git a/conf.d/zoxide.fish b/conf.d/zoxide.fish index 57a6efe..5d2319c 100644 --- a/conf.d/zoxide.fish +++ b/conf.d/zoxide.fish @@ -3,6 +3,9 @@ # Adapted from icezyclon/zoxide.fish (MIT) # Heavily customized for Fish 4.x compatibility and performance +# COMPONENT +# aliases/filesystem + if status is-interactive if type -q zoxide @@ -65,7 +68,7 @@ if status is-interactive # Shadowing cd with zoxide is opinionated (C1 aliasing); z and zi # remain available either way. - if __fish_config_op_enabled __fish_config_op_aliases + if __fish_config_op_enabled (status basename) alias cd=z end diff --git a/config.fish b/config.fish index deef5fb..acae248 100644 --- a/config.fish +++ b/config.fish @@ -7,7 +7,7 @@ # ───────────────────── Opinionated component guards ───────────────────── # Opinionated components (AGENTS.md Task #3) are wrapped in -# __fish_config_op_enabled guards throughout this file and conf.d/. +# __fish_config_op_enabled [] guards throughout this file and conf.d/. # The helper always evaluates the master switch __fish_config_opinionated # first (falsy disables everything), then the per-category opt-out variable: # __fish_config_op_aliases C1 — command shadows / flag injection @@ -21,12 +21,23 @@ # C5 is the one exception: it defaults to disabled and needs an explicit # truthy value — set -U __fish_config_op_logging on +# COMPONENT +# site cachyos-tricks: overrides/environment +# site cachyos-strip-aliases: aliases/filesystem +# site cachyos-strip-overrides: overrides/key-bindings +# site pager-editor-gpg: overrides/environment +# site exit-wiring: overrides/key-bindings +# site path-setup: overrides/environment +# site cdpath: overrides/environment +# site vi-mode: overrides/key-bindings +# site greeting-stamp: greeting/greeting-message + # ──────────────────────── Source CachyOS configs ──────────────────────── if test -f /usr/share/cachyos-fish-config/cachyos-config.fish source /usr/share/cachyos-fish-config/cachyos-config.fish # Surgically overriding the distro config is opinionated (C3 overrides): # skip it entirely when overrides are disabled, keeping CachyOS defaults. - if __fish_config_op_enabled __fish_config_op_overrides + if __fish_config_op_enabled (status basename) cachyos-tricks # Source our tricks over the cachyOS config test -f "$__fish_config_dir/conf.d/tricks.fish" and source "$__fish_config_dir/conf.d/tricks.fish" @@ -41,7 +52,7 @@ if test -f /usr/share/cachyos-fish-config/cachyos-config.fish # The distro config ships opinionated pieces of its own (it is the origin # of tricks.fish); strip them when the matching category is disabled so # the guards hold on CachyOS systems too. - if not __fish_config_op_enabled __fish_config_op_aliases + if not __fish_config_op_enabled (status basename) cachyos-strip-aliases for _fname in grep fgrep egrep dir vdir wget functions -q $_fname; and functions --erase $_fname end @@ -53,7 +64,7 @@ if test -f /usr/share/cachyos-fish-config/cachyos-config.fish and source $__fish_data_dir/functions/$_fname.fish end end - if not __fish_config_op_enabled __fish_config_op_overrides + if not __fish_config_op_enabled (status basename) cachyos-strip-overrides for _fname in __history_previous_command __history_previous_command_arguments functions -q $_fname; and functions --erase $_fname end @@ -95,7 +106,7 @@ set -q WORDLIST; or set -gx WORDLIST "$XDG_CONFIG_HOME/hunspell_en_US" # ─────────────────────────── Pager variables ──────────────────────────── # Overriding $PAGER, $EDITOR, and $GPG_TTY is opinionated (C3 overrides) -if __fish_config_op_enabled __fish_config_op_overrides +if __fish_config_op_enabled (status basename) pager-editor-gpg if type -q ov set -gx PAGER ov else if type -q less @@ -136,7 +147,7 @@ and set -gx SCROLLBACK_HISTORY_MAX_FILES $__fish_scrollback_history_max_files # Wire up a clean exit function that won't fire on background subshells # Replacing the exit builtin is opinionated (C3 overrides); smart_exit also # guards itself so a live toggle takes effect without restarting the shell. -if status is-interactive; and __fish_config_op_enabled __fish_config_op_overrides +if status is-interactive; and __fish_config_op_enabled (status basename) exit-wiring function exit --description 'Safe interactive exit' # If the smart_exit file exists in our function path, invoke it explicitly if functions -q smart_exit @@ -153,7 +164,7 @@ end # the cargo bin directory is moved to the end of the PATH, which can help avoid conflicts # with system-installed Rust tools while still allowing user-installed cargo binaries to be found. # PATH setup is opinionated (C3 overrides) -if __fish_config_op_enabled __fish_config_op_overrides +if __fish_config_op_enabled (status basename) path-setup fish_add_path $HOME/.local/bin # Standard user-local executables (XDG spec) fish_add_path $HOME/.local/share/../bin # Alternative/legacy path for local user binaries fish_add_path $HOME/Applications # User-installed applications and standalone apps @@ -176,7 +187,7 @@ end # so if you have a directory named 'myproject' in the current directory, # running 'cd myproject' will take you there instead of $HOME/projects/myproject. # CDPATH injection is opinionated (C3 overrides) -if __fish_config_op_enabled __fish_config_op_overrides +if __fish_config_op_enabled (status basename) cdpath set -gx CDPATH . $HOME/projects $HOME end @@ -191,7 +202,7 @@ if status is-interactive # This is optional but can improve the user experience for those who prefer Vi-style key bindings. # Global Vi mode is opinionated (C3 overrides); without it fish keeps its # default Emacs-style bindings. - if __fish_config_op_enabled __fish_config_op_overrides + if __fish_config_op_enabled (status basename) vi-mode set -g fish_key_bindings fish_vi_key_bindings end @@ -251,7 +262,7 @@ if status is-interactive # function that distro configs set (e.g., CachyOS defines it as fastfetch). # This runs last inside the interactive block so our empty definition wins # over whatever cachyos-config.fish or vendor conf.d installed. - if not __fish_config_op_enabled __fish_config_op_greeting + if not __fish_config_op_enabled (status basename) greeting-stamp function fish_greeting end end diff --git a/docs/build-manual.py b/docs/build-manual.py index 7b3070d..5f1227d 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -15,6 +15,7 @@ import sys from pathlib import Path import manualtools as mt +import generate_component_registry DOCS = Path(__file__).parent MANUAL = DOCS / "manual" @@ -840,6 +841,8 @@ def main() -> int: if not (args.concat or args.site): ap.error("nothing to do: pass --concat and/or --site") + generate_component_registry.main() + if args.site: src = DOCS / "site" / "src" out = src / "content" / "docs" diff --git a/docs/generate_component_registry.py b/docs/generate_component_registry.py new file mode 100644 index 0000000..6a1b9e6 --- /dev/null +++ b/docs/generate_component_registry.py @@ -0,0 +1,121 @@ +#!/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. + + Concatenates raw component lines when the same identity appears in + more than one source (e.g. functions/auto-pull.fish and + conf.d/auto-pull.fish both self-identify as "auto-pull" at runtime, + since the guard can only ever look up the bare status + current-function/basename string) rather than letting one silently + overwrite the other. + """ + out: dict[str, list[str]] = {} + for source in ( + mt.parse_components(REPO / "functions"), + mt.parse_components(REPO / "conf.d"), + mt.parse_component_file(REPO / "config.fish"), + ): + for identity, lines in source.items(): + out.setdefault(identity, []).extend(lines) + 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}"] = list(dict.fromkeys(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", + "#", + "# This file must be sourced before any other conf.d/*.fish file that", + "# calls the opinionated guard. That currently holds only because fish's", + "# glob-based conf.d loading happens to sort this filename first", + "# alphabetically among the guard-calling files -- do not rename it", + "# without preserving that ordering.", + "", + ] + 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" + + quoted_keys = [f'"{k}"' for k in keys] + lines.append("set -g __fish_config_op_registry_keys \\") + lines += [f" {k} \\" for k in quoted_keys[:-1]] + [f" {quoted_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)", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).parent)) + raise SystemExit(main()) diff --git a/docs/manual/07-customization.md b/docs/manual/07-customization.md index 3e9d162..81f2049 100644 --- a/docs/manual/07-customization.md +++ b/docs/manual/07-customization.md @@ -145,6 +145,19 @@ NOTE: - Disabled integration commands (spwin, tab, split, hist, logs, upgrade) print an error naming the variable that disabled them. - On CachyOS, the distro fish config's own aliases, history override, and bang-bang bindings are stripped per category as well. +### Sub-categories + +Each of the six categories further sub-divides into two to six +sub-categories, each with its own `__fish_config_op__` +variable (e.g. `__fish_config_op_aliases_filesystem`). These follow the +exact same truthy/falsy/unset cascade one level deeper: an explicit +sub-category value overrides the master switch and the parent category's +setting, and an unset sub-category inherits from its parent category (which +in turn inherits from `__fish_config_opinionated`). Run config-settings and +press Enter on a category row to browse and toggle its sub-categories +interactively. See [Components Reference](/08-components-reference/) for the +full sub-category breakdown of every category. + ## Prompt and Theme diff --git a/docs/manual/08-components-reference/01-c1-command-shadows.md b/docs/manual/08-components-reference/01-c1-command-shadows.md index c316630..eb3b5fb 100644 --- a/docs/manual/08-components-reference/01-c1-command-shadows.md +++ b/docs/manual/08-components-reference/01-c1-command-shadows.md @@ -31,3 +31,35 @@ all of these commands. When C1 is disabled, `rm` uses bare `command rm` with no wrapper — files are permanently deleted, not trashed. There is no intermediate safety net. +## Sub-categories + +`__fish_config_op_aliases` sub-divides into six sub-categories, each with +its own `__fish_config_op_aliases_` toggle: + +## filesystem + +`ls`, `cat`, `cd`, `du`, `mkdir`, `rm`, `mv`, and `cd`/zoxide navigation -- +the everyday filesystem-inspection and -modification shadows. + +## search + +`rg`, with its Kitty hyperlink formatting. + +## network + +`ping`, `ssh`, and `yt-dlp` -- shadows that talk to the network. + +## monitor + +`top` -> `btop`. + +## shell-tools + +`bash` (XDG bashrc + `$SHELL` reset), `less` (`$PAGER` fallback chain), +and the `help config` interception. + +## dev-tools + +`claude` (AGENTS.md/CLAUDE.md auto-linking) and `edit` (multi-editor +launcher), plus `agy`. + diff --git a/docs/manual/08-components-reference/02-c2-startup-side-effects.md b/docs/manual/08-components-reference/02-c2-startup-side-effects.md index e2f0eb5..e9c8b49 100644 --- a/docs/manual/08-components-reference/02-c2-startup-side-effects.md +++ b/docs/manual/08-components-reference/02-c2-startup-side-effects.md @@ -43,3 +43,28 @@ branches, or repos without a remote. The handler fires once per repo entry (not on every sub-directory `cd`). The registry is machine-local at `$__fish_user_dots_path/auto-pull.list` (defaults to `~/.config/.user-dots/fish/auto-pull.list`) and is never committed. +## Sub-categories + +`__fish_config_op_autoexec` sub-divides into five sub-categories, each +with its own `__fish_config_op_autoexec_` toggle: + +## plugin-management + +Fisher bootstrap on first run. + +## pkg-wrappers + +`paru`/`yay` wrapper generation. + +## venv + +Automatic Python virtualenv activation. + +## telemetry + +The WakaTime hook's startup bootstrap. + +## sync + +Auto-pull background fast-forward, and the user-dots convenience symlink. + diff --git a/docs/manual/08-components-reference/03-c3-key-and-environment-overrides.md b/docs/manual/08-components-reference/03-c3-key-and-environment-overrides.md index 79addf0..9e7102f 100644 --- a/docs/manual/08-components-reference/03-c3-key-and-environment-overrides.md +++ b/docs/manual/08-components-reference/03-c3-key-and-environment-overrides.md @@ -33,3 +33,23 @@ When C3 is disabled, `exit` falls back to `builtin exit` with no scrollback capture, no Kitty IPC, and no file I/O on exit. The scrollback capture block is independently controlled by C5 (see below). +## Sub-categories + +`__fish_config_op_overrides` sub-divides into three sub-categories, each +with its own `__fish_config_op_overrides_` toggle: + +## key-bindings + +Vi mode, autopair, puffer key intercepts, bang-bang history expansion, +and `smart_exit`'s plain-exit path. + +## environment + +`$PATH`, `$PAGER`/`$EDITOR`/`$GPG_TTY`, and `$CDPATH`. + +## prompt + +Starship, the right prompt, Catppuccin syntax/prompt colors, and FZF +theming (`$FZF_DEFAULT_OPTS`) -- all driven by the same guard as a single +unit, not independently toggleable from each other. + diff --git a/docs/manual/08-components-reference/04-c4-terminal-and-tool-integration.md b/docs/manual/08-components-reference/04-c4-terminal-and-tool-integration.md index 0219051..952df73 100644 --- a/docs/manual/08-components-reference/04-c4-terminal-and-tool-integration.md +++ b/docs/manual/08-components-reference/04-c4-terminal-and-tool-integration.md @@ -22,3 +22,28 @@ Disabled integration commands (`spwin`, `tab`, `split`, `hist`, `logs`, `upgrade a colored error to stderr naming the variable that disabled them rather than silently failing. +## Sub-categories + +`__fish_config_op_integrations` sub-divides into five sub-categories, +each with its own `__fish_config_op_integrations_` toggle: + +## terminal-abbrs + +The Kitty/WezTerm abbreviation set. + +## window-mgmt + +`spwin`, `tab`, `split`. + +## notifications + +`done`'s completion notifications, and the WakaTime activity hook. + +## history-logs + +`hist`, `logs`. + +## pkg-upgrade + +`upgrade`. + diff --git a/docs/manual/08-components-reference/05-c5-logging-and-capture.md b/docs/manual/08-components-reference/05-c5-logging-and-capture.md index a9f52e4..4867c1e 100644 --- a/docs/manual/08-components-reference/05-c5-logging-and-capture.md +++ b/docs/manual/08-components-reference/05-c5-logging-and-capture.md @@ -112,3 +112,22 @@ Note: C3 and C5 compose independently. C3 controls whether the smart_exit wrapper is active at all; C5 controls only the scrollback-capture block inside it. With C3 disabled, exit is plain builtin exit regardless of C5. +## Sub-categories + +`__fish_config_op_logging` sub-divides into three sub-categories, each +with its own `__fish_config_op_logging_` toggle (all still opt-in +by default, inherited from C5's own opt-in behavior -- see §3 of the +design spec): + +## terminal-capture + +Kitty watcher scrollback capture, and `smart_exit`'s logging-guard path. + +## multiplexer-capture + +tmux `pipe-pane` and zellij `dump-screen` capture. + +## pkg-logs + +`paru`/`yay` AUR log wrappers. + diff --git a/docs/manual/08-components-reference/06-c6-greeting-and-first-run-ui.md b/docs/manual/08-components-reference/06-c6-greeting-and-first-run-ui.md index 699e721..39b33f3 100644 --- a/docs/manual/08-components-reference/06-c6-greeting-and-first-run-ui.md +++ b/docs/manual/08-components-reference/06-c6-greeting-and-first-run-ui.md @@ -13,3 +13,16 @@ When C6 is disabled, no greeting is printed by this config. Any greeting set by the distro or other configs runs normally — this config simply does not override it. +## Sub-categories + +`__fish_config_op_greeting` sub-divides into two sub-categories, each +with its own `__fish_config_op_greeting_` toggle: + +## first-run + +The first-run welcome banner. + +## greeting-message + +The per-session `fish_greeting` override. + diff --git a/docs/manual/08-components-reference/index.md b/docs/manual/08-components-reference/index.md index 8e8fc9f..a433194 100644 --- a/docs/manual/08-components-reference/index.md +++ b/docs/manual/08-components-reference/index.md @@ -18,3 +18,18 @@ category variable. C4 [Integrations](/08-components-reference/04-c4-terminal-and-tool-integration/) — Kitty/Wezterm integrations, starship hooks, fzf theme C5 [Logging and Capture](/08-components-reference/05-c5-logging-and-capture/) — Session logs, command duration C6 [Greeting & First-Run UI](/08-components-reference/06-c6-greeting-and-first-run-ui/) — Custom startup banner + +Each category further sub-divides into two to six sub-categories (24 in +total) with their own `__fish_config_op__` toggles +-- see that category's page for its sub-category list. + +## Per-function overrides: `C0`/`always` + +Every guarded function or file can also carry a reserved `always/on` or +`always/off` tag in its `# COMPONENT` header, independent of every C1-C6 +category and sub-category toggle and invisible to `config-settings`. An +`always/off` tag disables that function unconditionally; an `always/on` +tag enables it unconditionally, ignoring the state of every other tagged +sub-category. This is a per-function escape hatch for cases too granular +or too idiosyncratic to justify a taxonomy entry -- edit the header +directly and run `__fish_config_op_registry_rebuild` to apply the change. diff --git a/docs/manual/12-troubleshooting.md b/docs/manual/12-troubleshooting.md index 166fec2..a823530 100644 --- a/docs/manual/12-troubleshooting.md +++ b/docs/manual/12-troubleshooting.md @@ -237,6 +237,13 @@ Re-enable everything: set -Ue __fish_config_opinionated +Each category also has two to six sub-categories (e.g. +`__fish_config_op_aliases_filesystem`) that can be checked, disabled, or +reset the same way — `set -U __fish_config_op__ off` +and `set -Ue __fish_config_op__` work identically to +the category-level recipes above, just one level more granular. See +[Components Reference](/08-components-reference/) for the full list. + For an interactive alternative to setting these variables by hand, run `config-settings`. --- diff --git a/docs/manualtools.py b/docs/manualtools.py index 7103653..70054a2 100644 --- a/docs/manualtools.py +++ b/docs/manualtools.py @@ -62,6 +62,7 @@ HEADER_LABEL = re.compile(r"^#\s+([A-Z][A-Z ]*[A-Z])\s*$") FUNC_DEF = re.compile(r"^\s*function\s+(\S+)") SECTIONS = ( "CATEGORY", + "COMPONENT", "DEPENDENCIES", "SYNOPSIS", "DESCRIPTION", @@ -109,6 +110,19 @@ def _trailing_blanks(lines: list[str]) -> int: return n +def _block_identity(path: Path, lines: list[str], end: int, blocks_count: int) -> str: + """Resolve a header block's associated name. + + A file carrying exactly one header is associated with its own stem, so + a `function` nested inside a `type -q` guard still resolves. A file + with several headers walks forward to the next `function` definition. + """ + if blocks_count == 1: + return path.stem + after = (m.group(1) for ln in lines[end:] if (m := FUNC_DEF.match(ln))) + return next(after, path.stem) + + def parse_functions(root: Path) -> dict[str, dict[str, list[str]]]: """Parse the comment header above every documented public function. @@ -128,11 +142,7 @@ def parse_functions(root: Path) -> dict[str, dict[str, list[str]]]: lines = path.read_text(encoding="utf-8").split("\n") blocks = _header_blocks(lines) for end, sections in blocks: - if len(blocks) == 1: - name = path.stem - else: - after = (m.group(1) for ln in lines[end:] if (m := FUNC_DEF.match(ln))) - name = next(after, path.stem) + name = _block_identity(path, lines, end, len(blocks)) if name.startswith("_") or "CATEGORY" not in sections: continue out[name] = { @@ -185,6 +195,63 @@ def parse_abbreviations(root: Path) -> dict[str, list[dict]]: return out +SITE_LINE_RE = re.compile(r"^site\s+(\S+):\s*(\S+)$") + + +def parse_component_lines(lines: list[str]) -> list[tuple[str, str]]: + """Parse raw `# COMPONENT` body lines into (site, tag) pairs. + + A line of the form `site : ` scopes to that site; a + bare `` line belongs to the default (unnamed) site, keyed "". + Blank lines are skipped. + """ + out: list[tuple[str, str]] = [] + for line in lines: + line = line.strip() + if not line: + continue + m = SITE_LINE_RE.match(line) + if m: + out.append((m.group(1), m.group(2))) + else: + out.append(("", line)) + return out + + +def _parse_component_blocks(path: Path) -> dict[str, list[str]]: + """Parse every `# COMPONENT` header block in one file. + + Unlike parse_functions, there is no `# CATEGORY` gate and no + underscore exclusion: component classification applies to every + function/script, public or private, documented in the manual or not + -- the registry needs to see every guarded identity, not just the + ones that appear in the public function reference. + """ + lines = path.read_text(encoding="utf-8").split("\n") + blocks = _header_blocks(lines) + out: dict[str, list[str]] = {} + for end, sections in blocks: + if "COMPONENT" not in sections: + continue + name = _block_identity(path, lines, end, len(blocks)) + body = sections["COMPONENT"] + out[name] = body[: len(body) - _trailing_blanks(body)] + return out + + +def parse_component_file(path: Path) -> dict[str, list[str]]: + """Parse `# COMPONENT` header block(s) in one specific file (e.g. config.fish).""" + return _parse_component_blocks(path) + + +def parse_components(root: Path) -> dict[str, list[str]]: + """Parse `# COMPONENT` header blocks across every `*.fish` file under root.""" + out: dict[str, list[str]] = {} + for path in sorted(root.glob("*.fish")): + out.update(_parse_component_blocks(path)) + return out + + def _sort_key(entry: Path) -> tuple: """Order by sidebar.order when present, else by filename. Stable.""" target = entry / "index.md" if entry.is_dir() else entry diff --git a/docs/verify-manual.py b/docs/verify-manual.py index d242012..f29cf1b 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -130,6 +130,38 @@ def _parsed_functions() -> dict[str, dict[str, list[str]]]: return mt.parse_functions(Path(__file__).parent.parent / "functions") +def _parsed_components() -> dict[str, list[str]]: + import generate_component_registry as gcr + + return gcr.collect_components() + + +_C0_TAGS = {"always/on", "always/off"} +_TAXONOMY_FILES = { + "aliases": "01-c1-command-shadows.md", + "autoexec": "02-c2-startup-side-effects.md", + "overrides": "03-c3-key-and-environment-overrides.md", + "integrations": "04-c4-terminal-and-tool-integration.md", + "logging": "05-c5-logging-and-capture.md", + "greeting": "06-c6-greeting-and-first-run-ui.md", +} + + +def _load_taxonomy() -> dict[str, set[str]]: + """{category: {sub-category slugs}}, parsed from `## ` headings + in each category's docs/manual/08-components-reference/ file.""" + ref_root = Path(__file__).parent / "manual" / "08-components-reference" + taxonomy: dict[str, set[str]] = {} + for category, filename in _TAXONOMY_FILES.items(): + _, body = mt.parse(ref_root / filename) + taxonomy[category] = { + m.group(1) + for ln in body.split("\n") + if (m := re.match(r"^## ([a-z][a-z0-9-]*)$", ln)) + } + return taxonomy + + def test_every_categorised_function_produces_one_entry(): import build_manual @@ -184,6 +216,36 @@ def test_dependencies_resolve(): assert not dangling, "unresolvable # DEPENDENCIES:\n " + "\n ".join(dangling) +def test_every_component_resolves_to_a_taxonomy_entry(): + """Every non-C0 # COMPONENT tag must resolve to a documented sub-category.""" + taxonomy = _load_taxonomy() + unknown = [] + for identity, raw_lines in _parsed_components().items(): + for site, tag in mt.parse_component_lines(raw_lines): + if tag in _C0_TAGS: + continue + if "/" not in tag: + unknown.append(f"{identity}: malformed tag {tag!r}") + continue + category, subcat = tag.split("/", 1) + if category not in taxonomy or subcat not in taxonomy[category]: + unknown.append(f"{identity}: {tag}") + assert not unknown, "# COMPONENT tags with no taxonomy entry:\n " + "\n ".join(unknown) + + +def warn_c0_tags_never_combine_with_contradiction(): + """Warn -- never fail -- on always/on + always/off contradictions this + repo's own generator would warn about -- this is a direct repo-content + check, independent of running the generator, so CI surfaces them even + if someone forgets to regenerate. Warnings are non-fatal (spec §4.5); + this exists to print them prominently in CI output.""" + from generate_component_registry import build_registry + + _, warnings = build_registry(_parsed_components()) + for w in warnings: + print(f" WARN {w}") + + def warn_public_functions_without_category(): """Warn — never fail — on a public function carrying no `# CATEGORY`. @@ -205,6 +267,63 @@ def warn_public_functions_without_category(): print(" " + ", ".join(orphans)) +# The guard's own supporting infrastructure: these files' bodies (function +# signature, SYNOPSIS/EXAMPLE prose) legitimately contain the literal text +# "__fish_config_op_enabled" without being a *caller* of the guard, so they +# are permanently exempt from warn_functions_without_component's substring +# check below. +_GUARD_INFRA_FILES = { + "__fish_config_op_enabled.fish", + "__fish_config_op_cascade.fish", + "__fish_config_op_registry_lookup.fish", +} + + +def warn_functions_without_component(): + """Warn -- never fail -- on a documented function calling the + opinionated guard but carrying no `# COMPONENT` section. + + Mirrors warn_public_functions_without_category: a function that never + opted into the header convention at all (no # SYNOPSIS) is silently + out of scope, matching spec §4.5's fail-open tiering. + """ + repo = Path(__file__).parent.parent + components = _parsed_components() + orphans = [] + for p in list((repo / "functions").glob("*.fish")) + list((repo / "conf.d").glob("*.fish")): + if p.name in _GUARD_INFRA_FILES: + continue + text = p.read_text(encoding="utf-8") + if "__fish_config_op_enabled" not in text or "# SYNOPSIS" not in text: + continue + if p.stem not in components: + orphans.append(str(p.relative_to(repo))) + if orphans: + print(f" WARN {len(orphans)} function(s) call the opinionated guard but lack # COMPONENT:") + print(" " + ", ".join(sorted(orphans))) + + +def warn_unused_taxonomy_entries(): + """Warn -- never fail -- on a documented sub-category with zero tagged functions.""" + taxonomy = _load_taxonomy() + used: dict[str, set[str]] = {c: set() for c in taxonomy} + for raw_lines in _parsed_components().values(): + for _site, tag in mt.parse_component_lines(raw_lines): + if tag in _C0_TAGS or "/" not in tag: + continue + category, subcat = tag.split("/", 1) + if category in used: + used[category].add(subcat) + unused = [ + f"{category}/{subcat}" + for category, subcats in taxonomy.items() + for subcat in sorted(subcats - used[category]) + ] + if unused: + print(f" WARN {len(unused)} taxonomy entr{'y has' if len(unused) == 1 else 'ies have'} zero tagged functions:") + print(" " + ", ".join(unused)) + + def _without_section_5(text: str) -> str: """Drop `# 5. FUNCTIONS REFERENCE` through the start of section 6. @@ -827,6 +946,207 @@ def test_site_avoids_reserved_dir(): ) +def test_parse_component_lines_default_and_named_sites(): + lines = [ + "aliases/filesystem", + "site exit-plain: overrides/key-bindings", + "site logging-guard: logging/terminal-capture", + "", + " ", + ] + got = mt.parse_component_lines(lines) + assert got == [ + ("", "aliases/filesystem"), + ("exit-plain", "overrides/key-bindings"), + ("logging-guard", "logging/terminal-capture"), + ], f"unexpected parse: {got}" + + +def test_parse_components_includes_underscore_prefixed_and_uncategorised(): + """Unlike parse_functions, parse_components has no # CATEGORY gate and + no underscore exclusion -- every guarded identity must be visible.""" + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "__private_helper.fish").write_text( + "# COMPONENT\n" + "# logging/terminal-capture\n" + "function __private_helper\n" + "end\n" + ) + (root / "no_category.fish").write_text( + "# COMPONENT\n" + "# aliases/filesystem\n" + "#\n" + "# SYNOPSIS\n" + "# no_category\n" + "function no_category\n" + "end\n" + ) + got = mt.parse_components(root) + assert got["__private_helper"] == ["logging/terminal-capture"] + assert got["no_category"] == ["aliases/filesystem"] + + +def test_parse_components_resolves_multi_header_file_to_function_name(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "multi.fish").write_text( + "# COMPONENT\n" + "# aliases/filesystem\n" + "function first_fn\n" + "end\n" + "\n" + "# COMPONENT\n" + "# aliases/network\n" + "function second_fn\n" + "end\n" + ) + got = mt.parse_components(root) + assert got == { + "first_fn": ["aliases/filesystem"], + "second_fn": ["aliases/network"], + }, f"unexpected resolution: {got}" + + +def test_parse_component_file_single_file(): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "config.fish" + path.write_text( + "# COMPONENT\n" + "# site greeting-block: greeting/greeting-message\n" + ) + got = mt.parse_component_file(path) + 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_collect_components_merges_identity_collisions_across_sources(): + """functions/auto-pull.fish and conf.d/auto-pull.fish both self-identify + as "auto-pull" at runtime -- the guard only ever has the bare + status current-function/basename string to look up with -- so + collect_components must concatenate their raw COMPONENT lines + rather than letting conf.d's entry silently overwrite functions'.""" + import generate_component_registry as gcr + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "functions").mkdir() + (root / "conf.d").mkdir() + (root / "functions" / "auto-pull.fish").write_text( + "# COMPONENT\n" + "# autoexec/sync\n" + "function auto-pull\n" + "end\n" + ) + (root / "conf.d" / "auto-pull.fish").write_text( + "# COMPONENT\n" + "# autoexec/sync\n" + ) + (root / "config.fish").write_text("") + + orig_repo = gcr.REPO + try: + gcr.REPO = root + got = gcr.collect_components() + finally: + gcr.REPO = orig_repo + + assert got["auto-pull"] == ["autoexec/sync", "autoexec/sync"], ( + f"both sources' tags should survive the merge, not overwrite: {got}" + ) + + +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}" + + +def test_build_manual_regenerates_registry_before_building(): + """docs/build-manual.py must regenerate the registry as a pre-step.""" + import build_manual + + assert hasattr(build_manual, "generate_component_registry"), ( + "build-manual.py must import generate_component_registry so its " + "main() can be called as a pre-step before --site/--concat run" + ) + + +def test_committed_registry_matches_headers(): + """The committed conf.d/__fish_config_op_registry.fish must match what + generate_component_registry.py would produce right now from the current + `# COMPONENT` headers -- otherwise CI has nothing catching drift.""" + import generate_component_registry as gcr + + registry, _ = gcr.build_registry(gcr.collect_components()) + assert gcr.render(registry) == gcr.OUTPUT.read_text(), ( + "conf.d/__fish_config_op_registry.fish is stale — run " + "__fish_config_op_registry_rebuild" + ) + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")] @@ -840,6 +1160,9 @@ def main() -> int: print(f" FAIL {t.__name__}: {e}", file=sys.stderr) failed += 1 warn_public_functions_without_category() + warn_functions_without_component() + warn_unused_taxonomy_entries() + warn_c0_tags_never_combine_with_contradiction() print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed") return 1 if failed else 0 diff --git a/functions/__auto_source_fallback_venv.fish b/functions/__auto_source_fallback_venv.fish index ab17f05..6ca0e83 100644 --- a/functions/__auto_source_fallback_venv.fish +++ b/functions/__auto_source_fallback_venv.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# COMPONENT +# autoexec/venv +# # SYNOPSIS # __auto_source_fallback_venv # @@ -16,7 +19,7 @@ function __auto_source_fallback_venv --on-variable PWD status --is-command-substitution; and return # Opinionated guard (C2): no automatic venv activation when disabled. - __fish_config_op_enabled __fish_config_op_autoexec; or return + __fish_config_op_enabled (status current-function); or return # 1. Skip if direnv is already managing this directory if set -q DIRENV_DIR; or test -e ".envrc" diff --git a/functions/__config_settings_draw.fish b/functions/__config_settings_draw.fish index 077081b..1d28444 100644 --- a/functions/__config_settings_draw.fish +++ b/functions/__config_settings_draw.fish @@ -182,7 +182,7 @@ function __config_settings_draw # ── Keybind hint ────────────────────────────────────────────────────── # string pad is width-aware (arrows count as 1 column) - set -l hint " ↑↓/kj move ←→/hl set Tab page q quit" + set -l hint " ↑↓/kj move ←→/hl set Enter sub-cats Tab pg q quit" printf '%s│%s%s%s│\n' $p $c_dim (string pad -r -w $iw -- $hint) $c_reset # ── Bottom border ───────────────────────────────────────────────────── diff --git a/functions/__config_settings_draw_subcat.fish b/functions/__config_settings_draw_subcat.fish new file mode 100644 index 0000000..c7dccd3 --- /dev/null +++ b/functions/__config_settings_draw_subcat.fish @@ -0,0 +1,151 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# __config_settings_draw_subcat +# +# DESCRIPTION +# Renders the sub-category drill-down page for one C1-C6 category: +# the category's own toggle at the top (still meaningful as the cascade +# default for its sub-categories), then one row per sub-category from +# __config_settings_subcats, sized dynamically instead of the fixed +# 6-row layout __config_settings_draw uses for the category list. +# Follows the same width-tier and center-padding conventions as +# __config_settings_draw so the panel doesn't visibly jump between the +# two pages. +# +# Label and description fields are defensively truncated to their field +# width before padding (string pad only ever grows a string, never +# shrinks it) -- sub-category labels/descriptions are static data from +# __config_settings_subcats, not authored per width-tier the way +# __config_settings_draw's own category descriptions are, so a couple of +# them are longer than the narrower tiers' fields (e.g. "Notifications" +# is 13 chars against a 12-char label field; several descriptions run +# well past the 17-char field at the narrowest tier). Truncating keeps +# the box perfectly rectangular in every case instead of only in the +# cases the static text happens to fit. +# +# ARGUMENTS +# cur_row 0-based highlighted row (0 = the category's own toggle; +# 1..N = sub-category rows) +# cur_scope "universal" or "session" +# category_var One of the six __fish_config_op_ names +# +# EXIT STATUS +# 0 Always +# +# EXAMPLE +# __config_settings_draw_subcat 1 universal __fish_config_op_aliases +function __config_settings_draw_subcat + set -l cur_row $argv[1] + set -l cur_scope $argv[2] + set -l category_var $argv[3] + + set -l c_ok (set_color green) + set -l c_err (set_color red) + set -l c_dim (set_color brblack) + set -l c_sel (set_color --bold magenta) + set -l c_head (set_color --bold cyan) + set -l c_reset (set_color normal) + + set -l rows (__config_settings_subcats $category_var) + set -l n (count $rows) + + # ── Width tier: matches __config_settings_draw's 6-col-per-side steps ── + set -l iw 50 + if test "$COLUMNS" -ge 90 + set iw 76 + else if test "$COLUMNS" -ge 86 + set iw 72 + else if test "$COLUMNS" -ge 82 + set iw 68 + end + set -l HBR (string repeat -n $iw '─') + set -l p (string repeat -n (math --scale=0 "max(0, ($COLUMNS - ($iw + 2)) / 2)") ' ') + + # Label field is 13 wide (one wider than __config_settings_draw's 12) -- + # the longest real sub-category label ("Notifications") is 13 chars. + # Description field absorbs the difference so every row still totals + # iw+2, matching the surrounding box lines exactly. + set -l label_w 13 + set -l desc_w (math $iw - 34) + + set -l cat_label (string replace -r '^__fish_config_op_' '' -- $category_var) + # Scope indicator: toggling a row on this page writes -U (Universal, + # persistent) or -g (Session, this-shell-only) -- the title must say + # which, since it isn't otherwise visible anywhere on the page. + set -l scope_label Universal + test "$cur_scope" = session; and set scope_label Session + # Title layout is "┌─ Sub-categories: