diff --git a/functions/__fish_help_header.fish b/functions/__fish_help_header.fish new file mode 100644 index 0000000..68920be --- /dev/null +++ b/functions/__fish_help_header.fish @@ -0,0 +1,141 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# __fish_help_header [args...] +# +# DESCRIPTION +# Prints 's man-page comment header as a help menu on stdout. +# Intended as the first statement of a user-facing function's body: +# +# __fish_help_header (status current-function) $argv; and return 0 +# +# Returns 1 -- printing nothing -- ONLY when args[1] is not a help flag. +# Every other outcome, including an unreadable or headerless source +# file, prints something and returns 0. That asymmetry is load-bearing: +# a return of 1 means "run the real body", and the real body of upgrade +# is `paru -Syu --noconfirm`. A parse failure must never return 1. +# +# Only args[1] is inspected, never the whole list. wake-lock, bkg, +# split and spwin take a command to run as their arguments, so +# scanning all of $argv would make `wake-lock rsync --help` print +# wake-lock's own help instead of running rsync. +# +# The header is read from the caller's source at call time rather than +# from the generated manual, so it cannot go stale between a header +# edit and a docs rebuild. +# +# ARGUMENTS +# name The calling function's name, from (status current-function) +# args... The caller's $argv, forwarded verbatim +# +# EXIT STATUS +# 0 Help was printed, including the degraded fallback +# 1 args[1] is not -h/--help; the caller should carry on +# +# EXAMPLE +# __fish_help_header (status current-function) $argv; and return 0 +# +# NOTES +# Section labels are those of the manual SSOT parser in +# docs/manualtools.py. CATEGORY, COMPONENT and DEPENDENCIES are build +# metadata and are suppressed; SYNOPSIS renders as USAGE and EXAMPLE as +# EXAMPLES. +function __fish_help_header --argument-names name + # First argument only -- see DESCRIPTION. + contains -- "$argv[2]" -h --help; or return 1 + + set -l c_ttl (set_color --bold) + set -l c_sec (set_color --bold brblue) + set -l c_rst (set_color normal) + set -l miss " No documentation header found. Try: help config $name" + + set -l file (functions -D -- $name 2>/dev/null) + if not test -f "$file" + # Quoted: set_color yields an EMPTY LIST under TERM=dumb, and an + # unquoted empty list in a concatenation annihilates the whole + # word -- the title line would silently vanish wherever colour is + # off, which is exactly where a test would be reading it. + echo "$c_ttl$name$c_rst" + echo $miss + return 0 + end + + # Collect the contiguous comment run directly above `function `, + # walking backwards. This resolves multi-header files (fish-deps, gi, + # y) without reimplementing manualtools._block_identity, and is more + # accurate at runtime: in dops.fish it finds the header above + # `function docker` rather than attributing it to the file stem. + # One blank separator line is tolerated -- sponge_filter_secrets.fish + # is the only file that has one, and JOB-BRIEF-FINDINGS.md records it + # so this skip is not mistaken for dead code. + set -l lines (string split \n -- (command cat $file)) + set -l pat '^\s*function\s+'(string escape --style=regex -- $name)'(\s|$)' + set -l start 0 + for i in (seq (count $lines)) + if string match -qr -- $pat $lines[$i] + set start $i + break + end + end + + set -l header + if test $start -gt 1 + set -l j (math $start - 1) + if test -z (string trim -- "$lines[$j]") + set j (math $j - 1) + end + while test $j -ge 1; and string match -q '#*' -- $lines[$j] + set -p header $lines[$j] + set j (math $j - 1) + end + end + + # Render. Comment lines before the first `# LABEL` -- the copyright + # preamble -- carry no label and are dropped, matching + # manualtools._header_blocks. + set -l skip CATEGORY COMPONENT DEPENDENCIES + set -l label "" + set -l out + for line in $header + set -l m (string match -r -- '^#\s+([A-Z][A-Z ]*[A-Z])\s*$' $line) + if set -q m[2] + set label $m[2] + contains -- $label $skip; and continue + set -l shown (string replace SYNOPSIS USAGE -- $label) + set shown (string replace EXAMPLE EXAMPLES -- $shown) + # One blank line before a heading, never two: the header's own + # `#` separator has usually already emitted one. + if set -q out[1]; and test -n (string trim -- "$out[-1]") + set -a out "" + end + set -a out "$c_sec$shown$c_rst" + continue + end + test -n "$label"; or continue + contains -- $label $skip; and continue + set -l body (string sub -s 2 -- $line) + if string match -q ' *' -- $body + set -a out " "(string sub -s 4 -- $body) + else + set -a out (string trim -- $body) + end + end + + # Trim the trailing blank separator, mirroring + # manualtools._trailing_blanks. + while set -q out[-1]; and test -z (string trim -- "$out[-1]") + set -e out[-1] + end + + echo "$c_ttl$name$c_rst" + if test (count $out) -eq 0 + echo $miss + else + # out[1] is always a heading -- a body line cannot precede the + # first label -- so this blank is never doubled. + echo "" + printf '%s\n' $out + end + return 0 +end diff --git a/functions/bd-pull.fish b/functions/bd-pull.fish index 87b60de..0def40d 100644 --- a/functions/bd-pull.fish +++ b/functions/bd-pull.fish @@ -23,6 +23,8 @@ # bd-pull myuser/myproject # bd-pull rootiest/fish-config function bd-pull --description 'Pull new Gitea issues into local Beads and link them' + __fish_help_header (status current-function) $argv; and return 0 + if not set -q argv[1]; echo "Need repo owner/name"; return 1; end if not set -q GITEA_TOKEN; echo "\$GITEA_TOKEN not set"; return 1; end diff --git a/functions/bkg.fish b/functions/bkg.fish index 4cd5f03..517dd27 100644 --- a/functions/bkg.fish +++ b/functions/bkg.fish @@ -23,6 +23,8 @@ # EXAMPLE # bkg firefox function bkg --description 'Execute bkg' + __fish_help_header (status current-function) $argv; and return 0 + # Check if a command was provided as an argument. if test -z "$argv[1]" set -l c_head (set_color --bold cyan) diff --git a/functions/branch.fish b/functions/branch.fish index 836320d..6143685 100644 --- a/functions/branch.fish +++ b/functions/branch.fish @@ -21,6 +21,8 @@ # EXAMPLE # branch feature/new-ui function branch --description 'Switch to or create a git branch' + __fish_help_header (status current-function) $argv; and return 0 + if not git rev-parse --is-inside-work-tree >/dev/null 2>&1 echo "Not a git repo." return 1 diff --git a/functions/check_fish_deps.fish b/functions/check_fish_deps.fish index 9bae21b..fd24581 100644 --- a/functions/check_fish_deps.fish +++ b/functions/check_fish_deps.fish @@ -14,5 +14,7 @@ # EXAMPLE # check_fish_deps function check_fish_deps --description 'Check all fish-related dependencies' + __fish_help_header (status current-function) $argv; and return 0 + fish-deps status end diff --git a/functions/claude-docs.fish b/functions/claude-docs.fish index a4979b5..1d7717b 100644 --- a/functions/claude-docs.fish +++ b/functions/claude-docs.fish @@ -15,5 +15,7 @@ # EXAMPLE # claude-docs function claude-docs --description 'Claude-code: Sync README with recent changes' + __fish_help_header (status current-function) $argv; and return 0 + claude "Analyze the recent changes and update the README.md to ensure all features, setup instructions, and examples are 100% accurate. Prune any obsolete information." end diff --git a/functions/claude-pr.fish b/functions/claude-pr.fish index 507d753..d38f36e 100644 --- a/functions/claude-pr.fish +++ b/functions/claude-pr.fish @@ -15,5 +15,7 @@ # EXAMPLE # claude-pr function claude-pr --description 'Claude-code: New branch, commit, push, and PR' + __fish_help_header (status current-function) $argv; and return 0 + claude "Act as a senior engineer. Execute this sequence: 1. Create a new git branch (kebab-case). 2. Stage changes and write a Conventional Commit message. 3. Self-verify the changes by running relevant build/test commands or linting. 4. Push to remote. 5. Create a PR to 'main' including a summary of changes and a 'Manual Verification' section containing a Markdown checklist (- [ ]) of specific, bite-sized steps required to manually verify the functionality." end diff --git a/functions/cleanup.fish b/functions/cleanup.fish index eaa30db..b908ee7 100644 --- a/functions/cleanup.fish +++ b/functions/cleanup.fish @@ -14,6 +14,8 @@ # EXAMPLE # cleanup function cleanup --description 'Log orphans to ~/.removed_orphans and remove them' + __fish_help_header (status current-function) $argv; and return 0 + set -l orphans (pacman -Qtdq) if test -n "$orphans" echo "📝 Logging orphans to ~/.removed_orphans..." diff --git a/functions/fast.fish b/functions/fast.fish index 5cc0dfe..7fec3cb 100644 --- a/functions/fast.fish +++ b/functions/fast.fish @@ -36,6 +36,8 @@ # EXAMPLE # fast function fast --description 'Placeholder for future fast utility' + __fish_help_header (status current-function) $argv; and return 0 + # ANSI Escape Codes (Standard 16-color palette) set -l bold "\e[1m" set -l italic "\e[3m" diff --git a/functions/fc.fish b/functions/fc.fish index 5abb0a1..a65e230 100644 --- a/functions/fc.fish +++ b/functions/fc.fish @@ -24,6 +24,8 @@ # fc # fc git function fc --description 'Edit and execute the last command (Bash-style fc)' + __fish_help_header (status current-function) $argv; and return 0 + set -l tmpfile (mktemp /tmp/fish_fc.XXXXXX).fish if count $argv >/dev/null diff --git a/functions/fish-deps.fish b/functions/fish-deps.fish index bf9839e..1e244ab 100644 --- a/functions/fish-deps.fish +++ b/functions/fish-deps.fish @@ -66,6 +66,13 @@ function fish-deps --description 'Manage fish shell dependencies' _fish_deps_status case install _fish_deps_install $flags + case -h --help + # Reuse the existing menu rather than the header renderer: it + # is richer, and it is already the text the unknown-subcommand + # path prints. Previously --help fell into `case '*'` and + # exited 1 with "Unknown subcommand: --help". + __fish_deps_help + return 0 case update _fish_deps_update case sync diff --git a/functions/fzf-update.fish b/functions/fzf-update.fish index 1a403ed..5297dc8 100644 --- a/functions/fzf-update.fish +++ b/functions/fzf-update.fish @@ -14,6 +14,8 @@ # EXAMPLE # fzf-update function fzf-update --description 'Install or upgrade fzf from git HEAD' + __fish_help_header (status current-function) $argv; and return 0 + if test -d ~/.fzf echo "Updating fzf..." git -C ~/.fzf pull --ff-only diff --git a/functions/gip.fish b/functions/gip.fish index 19cbd91..cc73b11 100644 --- a/functions/gip.fish +++ b/functions/gip.fish @@ -14,6 +14,8 @@ # EXAMPLE # gip function gip --description 'Show all public IP addresses' + __fish_help_header (status current-function) $argv; and return 0 + echo -n "IPv4: " curl -4 -s --max-time 2 https://icanhazip.com || echo "Not detected" echo -n "IPv6: " diff --git a/functions/gip4.fish b/functions/gip4.fish index 9a71d1f..5431be4 100644 --- a/functions/gip4.fish +++ b/functions/gip4.fish @@ -13,5 +13,7 @@ # EXAMPLE # gip4 function gip4 --wraps='curl' --description 'Get public IPv4 address' + __fish_help_header (status current-function) $argv; and return 0 + curl -4 -s https://icanhazip.com end diff --git a/functions/gip6.fish b/functions/gip6.fish index 735ffc6..52ca05c 100644 --- a/functions/gip6.fish +++ b/functions/gip6.fish @@ -21,6 +21,8 @@ # EXAMPLE # gip6 function gip6 --description 'Get public IPv6 address' + __fish_help_header (status current-function) $argv; and return 0 + # Use -6 to force IPv6 and --fail to catch network errors set -l ip (curl -6 -s --fail https://icanhazip.com 2>/dev/null) diff --git a/functions/hist.fish b/functions/hist.fish index b21c97c..4536efa 100644 --- a/functions/hist.fish +++ b/functions/hist.fish @@ -17,6 +17,8 @@ # EXAMPLE # hist function hist --description 'Search fish history and put it in the prompt' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/lD.fish b/functions/lD.fish index 10ee313..9849199 100644 --- a/functions/lD.fish +++ b/functions/lD.fish @@ -17,6 +17,8 @@ # EXAMPLE # lD ~/projects function lD --description 'List directories only' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --only-dirs --long --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/ld.fish b/functions/ld.fish index 5bc6b4a..4baa58e 100644 --- a/functions/ld.fish +++ b/functions/ld.fish @@ -17,6 +17,8 @@ # EXAMPLE # ld function ld --description 'Run lazydocker on the current Docker context' + __fish_help_header (status current-function) $argv; and return 0 + if not type -q docker echo "ld: docker is not installed" >&2 return 1 diff --git a/functions/limine-edit.fish b/functions/limine-edit.fish index f22a652..9f3b502 100644 --- a/functions/limine-edit.fish +++ b/functions/limine-edit.fish @@ -16,6 +16,8 @@ # EXAMPLE # limine-edit function limine-edit --description 'Safely edit and re-verify Limine configuration' + __fish_help_header (status current-function) $argv; and return 0 + # 1. Open the config with sudoedit sudoedit /boot/limine.conf diff --git a/functions/lock.fish b/functions/lock.fish index c1e61bc..302d29d 100644 --- a/functions/lock.fish +++ b/functions/lock.fish @@ -13,5 +13,7 @@ # EXAMPLE # lock function lock --wraps='loginctl' --description 'alias lock=loginctl' + __fish_help_header (status current-function) $argv; and return 0 + loginctl lock-session end diff --git a/functions/lsr.fish b/functions/lsr.fish index 344530d..d680556 100644 --- a/functions/lsr.fish +++ b/functions/lsr.fish @@ -17,6 +17,8 @@ # EXAMPLE # lsr ~/projects function lsr --description 'Reversed time-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --oneline --sort=modified --reverse --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lss.fish b/functions/lss.fish index 9973905..d0d09a8 100644 --- a/functions/lss.fish +++ b/functions/lss.fish @@ -17,6 +17,8 @@ # EXAMPLE # lss ~/downloads function lss --description 'Size-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --oneline --long --all --sort=size --icons --color=auto --hyperlink --color-scale=size --color-scale-mode=gradient $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lstree.fish b/functions/lstree.fish index 49993ea..7c1be9f 100644 --- a/functions/lstree.fish +++ b/functions/lstree.fish @@ -17,6 +17,8 @@ # EXAMPLE # lstree ~/projects/myapp function lstree --description 'Full recursive tree listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --tree --icons --color=auto --hyperlink=auto $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lt.fish b/functions/lt.fish index 6e0fcfe..b16a526 100644 --- a/functions/lt.fish +++ b/functions/lt.fish @@ -17,6 +17,8 @@ # EXAMPLE # lt ~/projects function lt --description 'Tree listing, depth 2' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --tree --level=2 --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/ltr.fish b/functions/ltr.fish index 11e80ab..35d6910 100644 --- a/functions/ltr.fish +++ b/functions/ltr.fish @@ -18,6 +18,8 @@ # EXAMPLE # ltr ~/projects function ltr --description 'Reversed time-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --long --all --sort=modified --icons --hyperlink --color=auto --color-scale=age --color-scale-mode=gradient $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lx.fish b/functions/lx.fish index 663be39..f467d11 100644 --- a/functions/lx.fish +++ b/functions/lx.fish @@ -17,6 +17,8 @@ # EXAMPLE # lx ~/projects function lx --description 'Extension-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --long --all --sort=extension --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/parur.fish b/functions/parur.fish index 62a65e1..6f6f92c 100644 --- a/functions/parur.fish +++ b/functions/parur.fish @@ -19,6 +19,8 @@ # EXAMPLE # parur function parur --description 'Interactively search and remove an installed package using fzf' + __fish_help_header (status current-function) $argv; and return 0 + set -l aur "" if type -q paru set aur paru diff --git a/functions/poke.fish b/functions/poke.fish index 8cff1c5..ea9bd59 100644 --- a/functions/poke.fish +++ b/functions/poke.fish @@ -21,6 +21,8 @@ # EXAMPLE # poke ~/projects/new/src/main.fish function poke --description 'touch with automatic parent directory creation' + __fish_help_header (status current-function) $argv; and return 0 + if test (count $argv) -eq 0 echo (set_color red)"poke: no file specified"(set_color normal) >&2 return 1 diff --git a/functions/ports.fish b/functions/ports.fish index 19c0e57..6836c2c 100644 --- a/functions/ports.fish +++ b/functions/ports.fish @@ -14,5 +14,7 @@ # EXAMPLE # ports function ports --wraps='sudo' --description 'Show active network listeners' + __fish_help_header (status current-function) $argv; and return 0 + sudo lsof -iTCP -sTCP:LISTEN -P -n end diff --git a/functions/qr.fish b/functions/qr.fish index 804ded5..50af5d7 100644 --- a/functions/qr.fish +++ b/functions/qr.fish @@ -19,6 +19,8 @@ # qr "https://example.com" # echo "hello" | qr function qr --description 'Generate a QR code from text or pipe' + __fish_help_header (status current-function) $argv; and return 0 + if type -q qrencode if set -q argv[1] echo $argv | qrencode -t utf8 diff --git a/functions/sbver.fish b/functions/sbver.fish index b9a807d..87d7965 100644 --- a/functions/sbver.fish +++ b/functions/sbver.fish @@ -24,6 +24,8 @@ # sbver # sbver --brief function sbver --description 'Verifies Secure Boot status of EFI binaries using sbctl' + __fish_help_header (status current-function) $argv; and return 0 + if not type -q sbctl echo "Error: 'sbctl' is not installed." return 1 diff --git a/functions/screensleep.fish b/functions/screensleep.fish index 452ff9e..0bb4442 100644 --- a/functions/screensleep.fish +++ b/functions/screensleep.fish @@ -14,6 +14,8 @@ # EXAMPLE # screensleep function screensleep --description 'Turn off the display using KDE PowerDevil' + __fish_help_header (status current-function) $argv; and return 0 + # Optional: 1-second delay to ensure no keystrokes wake it immediately sleep 1 busctl --user call \ diff --git a/functions/split.fish b/functions/split.fish index 618b42a..32b2695 100644 --- a/functions/split.fish +++ b/functions/split.fish @@ -29,6 +29,10 @@ # split # split -v nvim README.md function split --description 'Run a command in a new terminal split' + # -h is --horizontal here (see this function's own ARGUMENTS), + # so only the long form may reach the renderer. + test "$argv[1]" = --help; and __fish_help_header (status current-function) --help; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/spwin.fish b/functions/spwin.fish index 18fb036..9068790 100644 --- a/functions/spwin.fish +++ b/functions/spwin.fish @@ -24,6 +24,8 @@ # EXAMPLE # spwin function spwin --wraps='~/.config/kitty/spawn-window.sh' --description 'spawn window in kitty or wezterm' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/steam-dl.fish b/functions/steam-dl.fish index 3c4395d..7c7ab1c 100644 --- a/functions/steam-dl.fish +++ b/functions/steam-dl.fish @@ -14,6 +14,8 @@ # EXAMPLE # steam-dl function steam-dl --description 'Run Steam while inhibiting system sleep' + __fish_help_header (status current-function) $argv; and return 0 + echo "Inhibiting sleep while Steam downloads..." systemd-inhibit --why="Active Download" --who="User" --what=idle:sleep steam end diff --git a/functions/sudo-toggle.fish b/functions/sudo-toggle.fish index e46547f..c218e98 100644 --- a/functions/sudo-toggle.fish +++ b/functions/sudo-toggle.fish @@ -19,6 +19,8 @@ # EXAMPLE # sudo-toggle function sudo-toggle --description 'Toggle sudo password requirement on/off' + __fish_help_header (status current-function) $argv; and return 0 + # Check the file size using sudo stat to see if our bypass rule is active set -l file_size (sudo stat -c %s /etc/sudoers.d/nofail-toggle 2>/dev/null) diff --git a/functions/swapstat.fish b/functions/swapstat.fish index 55a26fe..f379d4c 100644 --- a/functions/swapstat.fish +++ b/functions/swapstat.fish @@ -15,6 +15,8 @@ # EXAMPLE # swapstat function swapstat --description 'View colorized zRAM and swappiness status' + __fish_help_header (status current-function) $argv; and return 0 + set -l swappiness (sysctl -n vm.swappiness) set -l zdata (zramctl --bytes --noheadings --output DATA,TOTAL /dev/zram0 2>/dev/null) diff --git a/functions/tab.fish b/functions/tab.fish index 6cb9c21..6590dce 100644 --- a/functions/tab.fish +++ b/functions/tab.fish @@ -25,6 +25,8 @@ # EXAMPLE # tab function tab --description 'Spawn a new tab in the current terminal' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/tmux-clean.fish b/functions/tmux-clean.fish index cf775c4..c31765e 100644 --- a/functions/tmux-clean.fish +++ b/functions/tmux-clean.fish @@ -14,6 +14,8 @@ # EXAMPLE # tmux-clean function tmux-clean --description 'Kill all tmux sessions except the current one' + __fish_help_header (status current-function) $argv; and return 0 + # Get a list of all session names that are NOT currently attached set sessions (tmux list-sessions -F '#{session_name} #{session_attached}' | string match -rv ' 1$' | string split -f1 ' ') diff --git a/functions/upgrade.fish b/functions/upgrade.fish index 68702bb..20d2fc9 100644 --- a/functions/upgrade.fish +++ b/functions/upgrade.fish @@ -21,6 +21,8 @@ # EXAMPLE # upgrade function upgrade --description 'Full system upgrade via paru or yay' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/wake-lock.fish b/functions/wake-lock.fish index 053556e..1c55ee8 100644 --- a/functions/wake-lock.fish +++ b/functions/wake-lock.fish @@ -22,6 +22,8 @@ # EXAMPLE # wake-lock rsync -avz src/ dest/ function wake-lock --description 'Run a command while inhibiting system sleep' + __fish_help_header (status current-function) $argv; and return 0 + if test (count $argv) -eq 0 set -l c_head (set_color --bold cyan) set -l c_cmd (set_color --bold) diff --git a/tests/test-help.fish b/tests/test-help.fish new file mode 100644 index 0000000..68ed662 --- /dev/null +++ b/tests/test-help.fish @@ -0,0 +1,282 @@ +#!/usr/bin/env fish +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Coverage for the header-driven --help renderer (__fish_help_header) and +# the repo-wide rule that every user-facing function handles -h/--help. +# +# Runs isolated (no `# MODE:` marker): every case spawns its own --no-config +# fish with an explicit fish_function_path, so none of it needs a loaded +# session -- only $repo_root/functions (or a throwaway fixture dir) on the +# child's function path. + +source (realpath (dirname (status filename)))/lib.fish + +# Helper: run ` $argv` in a throwaway fish that can see both $dir and +# this repo's real functions/, so a fixture function can call the real +# __fish_help_header. $dir is always mktemp -d output, never spaced. +function _help_probe --argument-names dir + env TERM=dumb fish --no-config -c \ + "set -g fish_function_path $dir $repo_root/functions; $argv[2..]" +end + +function test_help_renderer + set -l tmp (mktemp -d) + printf '%s\n' \ + '# Copyright (C) 2026 Rootiest' \ + '' \ + '# CATEGORY' \ + '# 99-fixture' \ + '#' \ + '# SYNOPSIS' \ + '# fixturefn [options]' \ + '#' \ + '# DESCRIPTION' \ + '# First paragraph.' \ + '#' \ + '# Second paragraph.' \ + '#' \ + '# ARGUMENTS' \ + '# -x Do the thing' \ + '# more Indented continuation' \ + '#' \ + '# EXAMPLE' \ + '# fixturefn -x' \ + 'function fixturefn' \ + ' __fish_help_header (status current-function) $argv; and return 0' \ + ' echo RAN-BODY' \ + 'end' >$tmp/fixturefn.fish + + set -l out (_help_probe $tmp 'fixturefn --help') + set -l code $status + set -l text (string join \n $out) + rm -rf $tmp + + set -l failed 0 + if test $code -ne 0 + echo " renderer exited $code, expected 0" + set failed 1 + end + if contains -- RAN-BODY $out + echo " body executed despite --help" + set failed 1 + end + if not contains -- USAGE $out + echo " missing USAGE heading (SYNOPSIS should render as USAGE)" + set failed 1 + end + if contains -- CATEGORY $out + echo " CATEGORY leaked into the menu" + set failed 1 + end + if not string match -q '* more Indented continuation*' -- $text + echo " nested ARGUMENTS indentation lost" + set failed 1 + end + # Index-based, not a glob: fish's `string match` glob `*` does not + # span newlines, so a pattern straddling two lines silently never + # matches and the assertion would pass for the wrong reason. + set -l i (contains -i -- " First paragraph." $out) + if test -z "$i" + echo " DESCRIPTION body missing entirely" + set failed 1 + else + # Indices hoisted: a command substitution inside a quoted index + # ("$out[(math ...)]") is a fish parse error, not an expansion. + set -l gap (math $i + 1) + set -l nxt (math $i + 2) + if test -n "$out[$gap]" + echo " multi-paragraph DESCRIPTION lost its blank line" + set failed 1 + else if test "$out[$nxt]" != " Second paragraph." + echo " second paragraph missing after the blank" + set failed 1 + end + end + test $failed -eq 0 +end + +function test_help_renderer_degrades_safely + # The renderer must return 1 ONLY when argv[1] is not a help flag. + # A missing or label-less header must still print and exit 0, because + # returning 1 hands control back to the caller's body -- and the body + # of upgrade(1) is `paru -Syu --noconfirm`. + set -l tmp (mktemp -d) + printf '%s\n' \ + 'function headerless' \ + ' __fish_help_header (status current-function) $argv; and return 0' \ + " touch $tmp/BODY-RAN" \ + 'end' >$tmp/headerless.fish + # A comment run carrying no `# LABEL` line at all. + printf '%s\n' \ + '# just an ordinary comment, no labels here' \ + 'function malformed' \ + ' __fish_help_header (status current-function) $argv; and return 0' \ + " touch $tmp/BODY-RAN" \ + 'end' >$tmp/malformed.fish + + set -l failed 0 + for fn in headerless malformed + set -l out (_help_probe $tmp "$fn --help") + set -l code $status + if test $code -ne 0 + echo " $fn --help exited $code, expected 0" + set failed 1 + end + if test (count $out) -eq 0 + echo " $fn --help printed nothing" + set failed 1 + end + if not contains -- $fn $out + echo " $fn --help did not name the function" + set failed 1 + end + if test -e $tmp/BODY-RAN + echo " $fn executed its body despite --help" + set failed 1 + rm -f $tmp/BODY-RAN + end + end + + # The inverse: no help flag must return 1 and let the body run. + _help_probe $tmp headerless >/dev/null 2>&1 + if not test -e $tmp/BODY-RAN + echo " body did NOT run when no help flag was passed" + set failed 1 + end + + rm -rf $tmp + # Explicit, never a trailing `if`: standing gotcha #5 -- an if with no + # branch taken resolves $status to 0 and the test would pass silently. + test $failed -eq 0 +end + +function test_help_never_executes_destructive_path + # These eight ignore $argv entirely, so before the header-driven help + # landed, `upgrade --help` ran `paru -Syu --noconfirm`. The check has + # to prove --help does NOT reach the destructive path *without* ever + # running it: every external binary the eight can reach is shadowed by + # a recording stub on PATH, and the recorder must stay empty. + # + # WARNING: a silent pass here means a MISSING STUB, not success. If a + # function shows neither an EXECUTED line nor its own help, its + # command is absent from the stub list below -- add it. A test that + # cannot fail proves nothing about a body that runs sudo pacman -Rns. + set -l tmp (mktemp -d) + mkdir -p $tmp/bin + set -l log $tmp/invoked.log + touch $log + + for b in sudo pacman paru yay loginctl busctl tmux systemd-inhibit \ + sudoedit limine-enroll-config limine-mkinitcpio sbctl git fzf steam + printf '#!/bin/sh\necho "$(basename "$0") $*" >> %s\n' $log >$tmp/bin/$b + chmod +x $tmp/bin/$b + end + + set -l failed 0 + for fn in cleanup fzf-update limine-edit lock screensleep sudo-toggle \ + tmux-clean upgrade + set -l out (env TERM=dumb PATH="$tmp/bin:$PATH" HOME=$tmp \ + fish --no-config -c \ + "set -g fish_function_path $repo_root/functions; $fn --help" 2>/dev/null) + set -l code $status + + if test $code -ne 0 + echo " $fn --help exited $code, expected 0" + set failed 1 + end + if not contains -- $fn $out + echo " $fn --help did not print its own help" + set failed 1 + end + set -l ran (string trim -- (command cat $log)) + if test -n "$ran" + echo " $fn --help EXECUTED: $ran" + set failed 1 + end + echo -n "" >$log + end + + rm -rf $tmp + test $failed -eq 0 +end + +# Functions published in the manual that are exempt from the -h/--help +# rule. Rationale per entry: AGENTS/specs/2026-09-07-header-driven-help-design.md +# §4. This array is the ONLY machine-readable copy of the exempt set. +# +# EXEMPT-A -- shadows a same-named binary, or forwards $argv to one named +# tool that owns its own --help. Intercepting would hide that tool's help, +# and for the C1-guarded shadows it also breaks the disabled-fallback +# contract, where the bare tool is supposed to answer. +set -g __help_exempt \ + agy antigravity-ide bash cat cdi cffetch cheat claude clone clonet \ + config-toggle copy docker du dusize fast-cli ffetch gitui gitup jr \ + joplin less ls mkdir mv paste ping rawfish rg rm search ssh top \ + view yt-dlp +# EXEMPT-B -- invoked by fish, never typed by a user. +set -a __help_exempt fish_prompt fish_right_prompt fish_mode_prompt \ + sponge_filter_secrets + +function test_every_user_facing_function_has_help + set -l failed 0 + set -l published + + for f in $repo_root/functions/*.fish + set -l lines (string split \n -- (command cat $f)) + # Published == carries a `# CATEGORY` block, matching + # manualtools.parse_functions. + contains -- "# CATEGORY" (string trim -- $lines); or continue + # Resolve the real defined name; the file stem can disagree with it + # (formerly dops.fish defined `docker` -- see JOB-BRIEF-FINDINGS.md + # §1, fixed by splitting it into dops.fish and docker.fish). + set -l name (string match -rg '^\s*function\s+(\S+)' -- $lines)[1] + test -n "$name"; or continue + set name (string trim -c "'\"" -- $name) + string match -q '_*' -- $name; and continue + set -a published $name + + contains -- $name $__help_exempt; and continue + + # Body == everything from the `function` line down, comment lines + # dropped, so a header that merely mentions --help cannot pass. + set -l body + set -l in_body 0 + for l in $lines + test $in_body -eq 1; or string match -qr '^\s*function\s' -- $l; and set in_body 1 + test $in_body -eq 1; or continue + string match -qr '^\s*#' -- $l; and continue + set -a body $l + end + if not string match -qr -- '__fish_help_header|_flag_help|h/help|--help' \ + (string join \n -- $body) + echo " $name: no -h/--help handling and not in \$__help_exempt" + set failed 1 + end + end + + # Guard against a stale exempt list: every exempt name must still be a + # published function. Catches renames and deletions. + for e in $__help_exempt + if not contains -- $e $published + echo " \$__help_exempt lists '$e', which is no longer published" + set failed 1 + end + end + + test $failed -eq 0 +end + +section "help: renderer" +check "full render: headings, indentation, multi-paragraph description" true (test_help_renderer; and echo true; or echo false) + +section "help: renderer degrades safely" +check "headerless/malformed functions still print and exit 0" true (test_help_renderer_degrades_safely; and echo true; or echo false) + +section "help: destructive paths" +check "eight functions never execute their destructive path on --help" true (test_help_never_executes_destructive_path; and echo true; or echo false) + +section "help: coverage" +check "every user-facing function has --help or is exempt" true (test_every_user_facing_function_has_help; and echo true; or echo false) + +report