diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 949c79105..3bf1a561b 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -4,7 +4,8 @@ import shtab from ._common import process_epilog from ..constants import * # NOQA -from ..helpers import archivename_validator # used to detect ARCHIVE args for dynamic completion +from ..helpers import archivename_validator, SortBySpec # used to detect ARCHIVE args for dynamic completion +from ..manifest import AI_HUMAN_SORT_KEYS # Dynamic completion for archive IDs (aid:...) # @@ -23,11 +24,15 @@ from ..helpers import archivename_validator # used to detect ARCHIVE args for d AID_BASH_FN_NAME = "_borg_complete_aid" AID_ZSH_FN_NAME = "_borg_complete_aid" +# Name of the helper function inserted for completing SortBySpec options +SORTBY_BASH_FN_NAME = "_borg_complete_sortby" +SORTBY_ZSH_FN_NAME = "_borg_complete_sortby" + # Global bash preamble that is prepended to the generated completion script. # It aggregates only what we need: # - wordbreak fixes for ':' and '=' so tokens like 'aid:' and '--repo=/path' stay intact # - a minimal dynamic completion helper for aid: archive IDs -BASH_PREAMBLE = r""" +BASH_PREAMBLE_TMPL = r""" # keep ':' and '=' intact so tokens like 'aid:' and '--repo=/path' stay whole if [[ ${COMP_WORDBREAKS-} == *:* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//:}; fi if [[ ${COMP_WORDBREAKS-} == *=* ]]; then COMP_WORDBREAKS=${COMP_WORDBREAKS//=}; fi @@ -75,6 +80,52 @@ _borg_complete_aid() { done <<< "$out" return 0 } + +# Complete comma-separated sort keys for any option with type=SortBySpec. +# Keys are validated against Borg's AI_HUMAN_SORT_KEYS. +_borg_complete_sortby() { + local cur="${COMP_WORDS[COMP_CWORD]}" + + # Extract value part for --opt=value forms; otherwise the value is the word itself + local val prefix_eq + if [[ "$cur" == *=* ]]; then + prefix_eq="${cur%%=*}=" + val="${cur#*=}" + else + prefix_eq="" + val="$cur" + fi + + # Split into head (selected keys + trailing comma if any) and fragment (last token being typed) + local head frag + if [[ "$val" == *,* ]]; then + head="${val%,*}," + frag="${val##*,}" + else + head="" + frag="$val" + fi + + # Build a comma-delimited list for cheap membership testing + local headlist + if [[ -n "$head" ]]; then + headlist=",${head%,}," + else + headlist="," # nothing selected yet + fi + + # Valid keys (embedded at generation time) + local keys=(___SORT_KEYS___) + + local k + for k in "${keys[@]}"; do + # skip already-selected keys + [[ "$headlist" == *",${k},"* ]] && continue + # match prefix of last fragment + [[ -n "$frag" && "$k" != "$frag"* ]] && continue + printf '%s\n' "${prefix_eq}${head}${k}" + done +} """ @@ -84,7 +135,7 @@ _borg_complete_aid() { # - We use zsh's $words/$CURRENT arrays to inspect the command line. # - Candidates are returned via `compadd`. # - We try to detect repo context from --repo=V, --repo V, -r=V, -rV, -r V. -ZSH_PREAMBLE = r""" +ZSH_PREAMBLE_TMPL = r""" # Complete aid: archive IDs by querying "borg repo-list --short" # Note: we only suggest the first 8 hex digits (short ID) for completion. _borg_complete_aid() { @@ -134,6 +185,50 @@ _borg_complete_aid() { compadd -Q -- $candidates return 0 } + +# Complete comma-separated sort keys for any option with type=SortBySpec. +_borg_complete_sortby() { + local cur + cur="${words[$CURRENT]}" + + local val prefix_eq + if [[ "$cur" == *"="* ]]; then + prefix_eq="${cur%%\=*}=" + val="${cur#*=}" + else + prefix_eq="" + val="$cur" + fi + + local head frag + if [[ "$val" == *","* ]]; then + head="${val%,*}," + frag="${val##*,}" + else + head="" + frag="$val" + fi + + local headlist + if [[ -n "$head" ]]; then + headlist=",${head%,}," + else + headlist="," # nothing selected yet + fi + + # Valid keys (embedded at generation time) + local -a keys=(___SORT_KEYS___) + + local -a candidates=() + local k + for k in ${keys[@]}; do + [[ "$headlist" == *",${k},"* ]] && continue + [[ -n "$frag" && "$k" != "$frag"* ]] && continue + candidates+=( "${prefix_eq}${head}${k}" ) + done + compadd -Q -- $candidates + return 0 +} """ @@ -156,6 +251,20 @@ def _attach_aid_completion(parser: argparse.ArgumentParser): action.complete = {"bash": AID_BASH_FN_NAME, "zsh": AID_ZSH_FN_NAME} # type: ignore[attr-defined] +def _attach_sortby_completion(parser: argparse.ArgumentParser): + """Tag all arguments with type SortBySpec with sort-key completion.""" + + for action in parser._actions: + # Recurse into subparsers + if isinstance(action, argparse._SubParsersAction): + for sub in action.choices.values(): + _attach_sortby_completion(sub) + continue + + if action.type is SortBySpec: + action.complete = {"bash": SORTBY_BASH_FN_NAME, "zsh": SORTBY_ZSH_FN_NAME} # type: ignore[attr-defined] + + class CompletionMixIn: def do_completion(self, args): """Output shell completion script for the given shell.""" @@ -165,7 +274,13 @@ class CompletionMixIn: # to enumerate archives and does not introduce any new commands or caching. parser = self.build_parser() _attach_aid_completion(parser) - preamble = {"bash": BASH_PREAMBLE, "zsh": ZSH_PREAMBLE} + _attach_sortby_completion(parser) + + # Build preambles with embedded SortBy keys + sort_keys = " ".join(AI_HUMAN_SORT_KEYS) + bash_preamble = BASH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys) + zsh_preamble = ZSH_PREAMBLE_TMPL.replace("___SORT_KEYS___", sort_keys) + preamble = {"bash": bash_preamble, "zsh": zsh_preamble} script = shtab.complete(parser, shell=args.shell, preamble=preamble) # nosec B604 print(script) diff --git a/src/borg/testsuite/archiver/completion_cmd_test.py b/src/borg/testsuite/archiver/completion_cmd_test.py index 32d646539..49e0e5e83 100644 --- a/src/borg/testsuite/archiver/completion_cmd_test.py +++ b/src/borg/testsuite/archiver/completion_cmd_test.py @@ -8,6 +8,7 @@ def test_bash_completion(archivers, request): archiver = request.getfixturevalue(archivers) output = cmd(archiver, "completion", "bash") assert "_borg_complete_aid() {" in output + assert "_borg_complete_sortby() {" in output def test_zsh_completion(archivers, request): @@ -15,3 +16,4 @@ def test_zsh_completion(archivers, request): archiver = request.getfixturevalue(archivers) output = cmd(archiver, "completion", "zsh") assert "_borg_complete_aid() {" in output + assert "_borg_complete_sortby() {" in output