Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 116 additions & 4 deletions src/borg/archiver/completion_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Completes archive names by default (e.g., "my-backup-2024")
- Completes archive IDs when prefixed with "aid:" (e.g., "aid:12345678")
- In zsh and fish, shows archive metadata (name, timestamp, user@host) as descriptions
(tcsh has no completion descriptions)
- Respects --repo/-r flags to query the correct repository

2. Sort keys (SortBySpec):
Expand Down Expand Up @@ -54,6 +55,8 @@
- Suggests common file size values (500M, 1G, 10G, 100G, 1T, etc.)
"""

import re

import shtab

from ._common import process_epilog
Expand Down Expand Up @@ -790,6 +793,103 @@
"""


TCSH_PREAMBLE_TMPL = r"""
# Dynamic completion helpers for tcsh

alias _borg_complete_timestamp 'date +"%Y-%m-%dT%H:%M:%S"'


alias _borg_complete_sortby "echo {SORT_KEYS}"
alias _borg_complete_filescachemode "echo {FCM_KEYS}"
alias _borg_help_topics "echo {HELP_CHOICES}"
alias _borg_complete_compression_spec "echo {COMP_SPEC_CHOICES}"
alias _borg_complete_chunker_params "echo {CHUNKER_PARAMS_CHOICES}"
alias _borg_complete_relative_time "echo {RELATIVE_TIME_CHOICES}"
alias _borg_complete_file_size "echo {FILE_SIZE_CHOICES}"

# Complete archive names (archive IDs when the current token starts with "aid:") and tags.
# These need the command line (for --repo/-r) and some logic, which tcsh cannot do itself:
# it has no functions, and an alias cannot use backquotes here because the completion rule
# calling the alias is backquoted already. So the work is done by a POSIX sh script, kept in
# a variable (a single-quoted csh string, hence no single quotes in it) and run via "sh -c".
set _borg_sh_complete = '{SH_COMPLETE}'

alias _borg_complete_archive 'sh -c "$_borg_sh_complete" borg-completion archive "$COMMAND_LINE"'
alias _borg_complete_tags 'sh -c "$_borg_sh_complete" borg-completion tags "$COMMAND_LINE"'
"""

# the sh script the tcsh preamble runs, as one line (`sh -c <script> borg-completion <mode> <line>`).
# It must not contain single quotes (it goes into a single-quoted csh string) nor backquotes (the
# completion rule invoking it is backquoted already).
TCSH_SH_COMPLETE = (
"mode=$1; line=$2; "
# derive repo context from the command line: --repo=V, --repo V, -r=V, -rV, or -r V
"repo=; prev=; "
"for w in $line; do "
"case $w in --repo=*) repo=${w#--repo=};; -r=*) repo=${w#-r=};; -r?*) repo=${w#-r};; esac; "
"case $prev in --repo|-r) repo=$w;; esac; "
"prev=$w; "
"done; "
'set --; if [ -n "$repo" ]; then set -- --repo "$repo"; fi; '
# the token being completed: the last one, empty if the line ends with a space
"cur=${line##* }; "
# avoid prompts and suppress errors, the output is used as completion candidates
"if [ $mode = tags ]; then "
'borg repo-list "$@" --format "{tags}{NL}" 2>/dev/null </dev/null'
' | tr , "\\n" | sed "s/^ *//;s/ *$//" | grep . | sort -u; '
'elif [ "${cur#aid:}" != "$cur" ]; then '
# print only the first 8 hex digits of the ID, like the other shells do
'borg repo-list "$@" --format "aid:{id}{NL}" 2>/dev/null </dev/null | cut -c1-12; '
"else "
'borg repo-list "$@" --format "{archive}{NL}" 2>/dev/null </dev/null; '
"fi"
)


# in a `p@N@`...`@` rule, one `if (...) <action>` clause, e.g.
# `if ( $#cmd >= 3 && ("$cmd[2]" == "key") && ("$cmd[3]" == "export") ) f`
TCSH_CLAUSE_RE = re.compile(r"if \( \$#cmd >= \d+ && (?P<checks>.*?) \) (?P<action>.*)")
TCSH_CHECK_RE = re.compile(r'\("\$cmd\[\d+\]" == "(?P<word>[^"]+)"\)')
# a `p@N@` rule as a whole
TCSH_RULE_RE = re.compile(r"^(?P<indent>\s*)'p@(?P<idx>\d+)@`(?P<setup>set cmd=[^;]+; )(?P<clauses>.*)`@' \\$")


def _tcsh_anchor_positional_patterns(script):
"""
Complete files/dirs for positionals of a subcommand, e.g. `borg umount <MOUNTPOINT>`.

shtab puts such completions into the `p@N@` rule of that positional, as a bare completion
pattern (`f`, `d`, ...) in an `if (...) <action>` clause. But tcsh runs these clauses as
commands and only uses their *output*, so a pattern there does nothing. tcsh can only apply
a pattern via a rule of its own, keyed off the preceding word - which works whenever the
positional directly follows its (sub)command.

TODO: remove this once we require a shtab release that includes tqdm/shtab#241 - with such
a shtab, no clause has a bare pattern as its action and this is a no-op.
"""
lines = []
for line in script.splitlines():
rule = TCSH_RULE_RE.match(line)
if not rule:
lines.append(line)
continue
keep = []
for clause in rule["clauses"].split("; "):
match = TCSH_CLAUSE_RE.fullmatch(clause)
action = match["action"] if match else None
if action is None or action.startswith(("echo ", "eval ")):
keep.append(clause) # not a pattern, tcsh can run this
continue
words = TCSH_CHECK_RE.findall(match["checks"])
# the positional is at index `idx`, the (sub)command words are at 2..len(words) + 1
if words and int(rule["idx"]) == len(words) + 1:
lines.append(f"{rule['indent']}'n/{words[-1]}/{action}/' \\")
# else: the pattern is for a later positional, tcsh cannot express that - drop it
if keep:
lines.append(f"{rule['indent']}'p@{rule['idx']}@`{rule['setup']}{'; '.join(keep)}`@' \\")
return "\n".join(lines)


def _attach_completion(parser: ArgumentParser, type_class, completion_dict: dict):
"""Tag all arguments with type `type_class` with completion choices from `completion_dict`."""

Expand Down Expand Up @@ -839,9 +939,9 @@ def do_completion(self, args):
self.prog = prog

def for_all_shells(fn_name):
# same-named completion function in the bash, zsh and fish preambles;
# fish needs a command substitution to call it
return {"bash": fn_name, "zsh": fn_name, "fish": f"({fn_name})"}
# same-named completion helper in the bash, zsh, tcsh and fish preambles;
# fish needs a command substitution to call it, tcsh backquotes
return {"bash": fn_name, "zsh": fn_name, "tcsh": f"`{fn_name}`", "fish": f"({fn_name})"}

_attach_completion(parser, archivename_validator, for_all_shells("_borg_complete_archive"))
_attach_completion(parser, SortBySpec, for_all_shells("_borg_complete_sortby"))
Expand Down Expand Up @@ -904,12 +1004,20 @@ def for_all_shells(fn_name):
"RELATIVE_TIME_CHOICES": relative_time_choices_str,
"FILE_SIZE_CHOICES": file_size_choices_str,
"HELP_CHOICES": help_choices,
"SH_COMPLETE": TCSH_SH_COMPLETE,
}
preamble_templates = {
"bash": BASH_PREAMBLE_TMPL,
"zsh": ZSH_PREAMBLE_TMPL,
"tcsh": TCSH_PREAMBLE_TMPL,
"fish": FISH_PREAMBLE_TMPL,
}
preamble_templates = {"bash": BASH_PREAMBLE_TMPL, "zsh": ZSH_PREAMBLE_TMPL, "fish": FISH_PREAMBLE_TMPL}
template = preamble_templates.get(args.shell)
# Build the preamble using partial_format to avoid escaping braces etc.
preambles = [partial_format(template, mapping)] if template else []
script = parser.get_completion_script(f"shtab-{args.shell}", preambles=preambles)
if args.shell == "tcsh":
script = _tcsh_anchor_positional_patterns(script)
print(script)

def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
Expand All @@ -923,6 +1031,10 @@ def build_parser_completion(self, subparsers, common_parser, mid_common_parser):
completion script will call borg to query the repository. This will work best
if that call can be made without prompting for user input, so you may want to
set BORG_REPO and BORG_PASSPHRASE environment variables.

In tcsh, completions for positional arguments are matched by word position, so
e.g. an archive name is only completed if no options precede it - one more
reason to use BORG_REPO there rather than --repo.
"""
)

Expand Down
56 changes: 56 additions & 0 deletions src/borg/testsuite/archiver/completion_cmd_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import functools
import os
import re
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -40,6 +41,7 @@ def bash_version():
needs_bash4 = pytest.mark.skipif(bash_version() < 4, reason="Bash >= 4 not available")
needs_zsh = pytest.mark.skipif(not cmd_available("zsh --version"), reason="Zsh not available")
needs_fish = pytest.mark.skipif(not cmd_available("fish --version"), reason="Fish not available")
needs_tcsh = pytest.mark.skipif(not cmd_available("tcsh --version"), reason="Tcsh not available")


def _run_bash_completion_fn(completion_script, setup_code):
Expand Down Expand Up @@ -119,6 +121,42 @@ def test_fish_completion_nontrivial(archivers, request):
assert output.count("\n") > 100, f"Fish completion suspiciously few lines: {output.count(chr(10))}"


def test_tcsh_completion_nontrivial(archivers, request):
"""Verify the generated Tcsh completion is non-trivially sized."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "tcsh")
assert len(output) > 1000, f"Tcsh completion suspiciously small: {len(output)} chars"
assert output.count("\n") > 20, f"Tcsh completion suspiciously few lines: {output.count(chr(10))}"


def test_tcsh_completion_dynamic_helpers(archivers, request):
"""Verify the tcsh script has the dynamic archive/tag helpers and uses them."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "tcsh")
assert "alias _borg_complete_archive " in output, "no archive completion helper"
assert "alias _borg_complete_tags " in output, "no tag completion helper"
# the helper scripts are single-quoted csh strings run by sh, see the tcsh preamble
helper = output.split("set _borg_sh_complete = '", 1)[1].split("'", 1)[0]
assert "aid:{id}{NL}" in helper and "{archive}{NL}" in helper and "{tags}{NL}" in helper
assert "`" not in helper, "backquotes in the helper would nest inside the completion rules"
assert "eval _borg_complete_archive" in output, "archive completion not used for ARCHIVE"
assert "'n/--tags/`_borg_complete_tags`/'" in output, "tag completion not used for --tags"


def test_tcsh_completion_positional_patterns(archivers, request):
"""Verify positionals of a subcommand get a rule tcsh can apply, e.g. the umount mountpoint."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "tcsh")
assert "'n/umount/d/'" in output, "no directory completion for the umount mountpoint"
assert "'n/export/f/'" in output, "no file completion for the key export path"
# such a pattern is useless inside a `p@N@` rule, tcsh runs those clauses as commands
for rule in re.findall(r"'p@\d+@`(.*?)`@'", output):
_setup, _, clauses = rule.partition("; ") # drop the `set cmd=(...)` part
for clause in clauses.split("; "):
action = clause.rpartition(") ")[2]
assert action.startswith(("echo ", "eval ")), f"bare completion pattern in rule: {rule}"


# -- syntax validation --------------------------------------------------------


Expand Down Expand Up @@ -161,6 +199,24 @@ def test_fish_completion_syntax(archivers, request):
assert result.returncode == 0, f"Generated Fish completion has syntax errors: {result.stderr.decode()}"


@needs_tcsh
def test_tcsh_completion_syntax(archivers, request):
"""Verify the generated Tcsh completion script has valid syntax."""
archiver = request.getfixturevalue(archivers)
output = cmd(archiver, "completion", "tcsh")
# tcsh doesn't have -n for syntax check like bash/zsh, but we can try to source it
# and see if it fails. 'tcsh -f -c "source path"'
with tempfile.NamedTemporaryFile(mode="w", suffix=".tcsh", delete=False) as f:
f.write(output)
script_path = f.name
try:
# -f: fast start (don't resource .tcshrc)
result = subprocess.run(["tcsh", "-f", "-c", f"source {script_path}"], capture_output=True)
finally:
os.unlink(script_path)
assert result.returncode == 0, f"Generated Tcsh completion has errors: {result.stderr.decode()}"


# -- borg-specific preamble function behavior (bash) --------------------------


Expand Down
Loading