Skip to content
Draft
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
199 changes: 191 additions & 8 deletions src/borg/archiver/completion_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,144 @@
"""


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}"
"""


def _monkeypatch_shtab():
"""
Monkeypatches shtab's tcsh completion logic to fix severe parsing issues and add missing features.

1. Subcommand Positional Completion: shtab lacks native support for auto-completing positional
arguments that belong to subcommands in tcsh (e.g., `borg help <topic>`). This builds a
conditional evaluation structure (`if ( $#cmd >= X && ... )`) to support them.
2. Subshell Array Indexing Fix: `tcsh` aggressively evaluates array indices like `$cmd[2]` even
if the array is smaller than the requested index, causing "if: Empty if." errors. Added
explicit bounds checking (`$#cmd >= max_idx`).
3. Nested Subshell Safety: Standard shtab nests subshells using backticks which causes recursive
parsing crashes in tcsh. Replaced with safe `eval` usage.
"""
import shtab
from shtab import CHOICE_FUNCTIONS, complete2pattern
from collections import defaultdict
from argparse import SUPPRESS
from string import Template

def patched_complete_tcsh(parser, root_prefix=None, preamble="", choice_functions=None):
optionals_single = set()
optionals_double = set()
specials = []
index_choices = defaultdict(dict)

choice_type2fn = {k: v["tcsh"] for k, v in CHOICE_FUNCTIONS.items()}

if choice_functions:
choice_type2fn.update(choice_functions)

def get_specials(arg, arg_type, arg_sel):
if arg.choices:
choice_strs = " ".join(map(str, arg.choices))
yield f"'{arg_type}/{arg_sel}/({choice_strs})/'"
elif hasattr(arg, "complete"):
complete_fn = complete2pattern(arg.complete, "tcsh", choice_type2fn)
if complete_fn:
yield f"'{arg_type}/{arg_sel}/{complete_fn}/'"

def recurse_parser(cparser, positional_idx, requirements=None):
if requirements is None:
requirements = []

for optional in cparser._get_optional_actions():
if optional.help != SUPPRESS:
for optional_str in optional.option_strings:
if optional_str.startswith("--"):
optionals_double.add(optional_str[2:])
elif optional_str.startswith("-"):
optionals_single.add(optional_str[1:])
specials.extend(get_specials(optional, "n", optional_str))
if optional.nargs != 0:
specials.extend(get_specials(optional, "c", optional_str + "="))

for positional in cparser._get_positional_actions():
if positional.help != SUPPRESS:
positional_idx += 1
index_choices[positional_idx][tuple(requirements)] = positional
if isinstance(positional.choices, dict):
for subcmd, subparser in positional.choices.items():
recurse_parser(subparser, positional_idx, requirements + [subcmd])

recurse_parser(parser, 0)

for idx, ndict in index_choices.items():
if len(ndict) == 1:
arg = list(ndict.values())[0]
specials.extend(get_specials(arg, "p", str(idx)))
else:
nlist = []
for nn, arg in ndict.items():
max_idx = len(nn) + 1
checks = [f'("$cmd[{iidx}]" == "{n}")' for iidx, n in enumerate(nn, start=2)]
condition = f"$#cmd >= {max_idx} && " + " && ".join(checks)
if arg.choices:
choices_str = " ".join(map(str, arg.choices))
nlist.append(f"if ( {condition} ) echo {choices_str}")
elif hasattr(arg, "complete"):
complete_fn = complete2pattern(arg.complete, "tcsh", choice_type2fn)
if complete_fn:
if complete_fn.startswith("`") and complete_fn.endswith("`"):
func_name = complete_fn.strip("`")
nlist.append(f"if ( {condition} ) eval {func_name}")
else:
nlist.append(f"if ( {condition} ) {complete_fn}")
if nlist:
nlist_str = "; ".join(nlist)
padding = '"" "" "" "" "" "" "" "" ""'
specials.append(f"'p@{str(idx)}@`set cmd=(\"$COMMAND_LINE\" {padding}); {nlist_str}`@'")

if optionals_double:
if optionals_single:
optionals_single.add("-")
else:
optionals_single = ("-", "-")

specials = list(dict.fromkeys(specials))

return Template(
"""\
# AUTOMATICALLY GENERATED by `shtab`

${preamble}

complete ${prog} \\
'c/--/(${optionals_double_str})/' \\
'c/-/(${optionals_single_str})/' \\
${optionals_special_str} \\
'p/*/()/'"""
).safe_substitute(
preamble=("\n# Custom Preamble\n" + preamble + "\n# End Custom Preamble\n" if preamble else ""),
root_prefix=root_prefix,
prog=parser.prog,
optionals_double_str=" ".join(sorted(optionals_double)),
optionals_single_str=" ".join(sorted(optionals_single)),
optionals_special_str=" \\\n ".join(specials),
)

shtab.complete_tcsh = patched_complete_tcsh
shtab._SUPPORTED_COMPLETERS["tcsh"] = patched_complete_tcsh


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 @@ -659,32 +797,72 @@ def do_completion(self, args):
# adds dynamic completion for archive IDs with the aid: prefix for all ARCHIVE
# arguments (identified by archivename_validator). It reuses `borg repo-list`
# to enumerate archives and does not introduce any new commands or caching.
_monkeypatch_shtab()
parser = self.build_parser()
_attach_completion(
parser, archivename_validator, {"bash": "_borg_complete_archive", "zsh": "_borg_complete_archive"}
)
_attach_completion(parser, SortBySpec, {"bash": "_borg_complete_sortby", "zsh": "_borg_complete_sortby"})

_attach_completion(
parser, FilesCacheMode, {"bash": "_borg_complete_filescachemode", "zsh": "_borg_complete_filescachemode"}
parser,
SortBySpec,
{"bash": "_borg_complete_sortby", "zsh": "_borg_complete_sortby", "tcsh": "`_borg_complete_sortby`"},
)
_attach_completion(
parser,
FilesCacheMode,
{
"bash": "_borg_complete_filescachemode",
"zsh": "_borg_complete_filescachemode",
"tcsh": "`_borg_complete_filescachemode`",
},
)
_attach_completion(
parser,
CompressionSpec,
{"bash": "_borg_complete_compression_spec", "zsh": "_borg_complete_compression_spec"},
{
"bash": "_borg_complete_compression_spec",
"zsh": "_borg_complete_compression_spec",
"tcsh": "`_borg_complete_compression_spec`",
},
)
_attach_completion(parser, PathSpec, shtab.DIRECTORY)
_attach_completion(
parser, ChunkerParams, {"bash": "_borg_complete_chunker_params", "zsh": "_borg_complete_chunker_params"}
parser,
ChunkerParams,
{
"bash": "_borg_complete_chunker_params",
"zsh": "_borg_complete_chunker_params",
"tcsh": "`_borg_complete_chunker_params`",
},
)
_attach_completion(parser, tag_validator, {"bash": "_borg_complete_tags", "zsh": "_borg_complete_tags"})
_attach_completion(
parser,
relative_time_marker_validator,
{"bash": "_borg_complete_relative_time", "zsh": "_borg_complete_relative_time"},
{
"bash": "_borg_complete_relative_time",
"zsh": "_borg_complete_relative_time",
"tcsh": "`_borg_complete_relative_time`",
},
)
_attach_completion(parser, timestamp, {"bash": "_borg_complete_timestamp", "zsh": "_borg_complete_timestamp"})
_attach_completion(
parser, parse_file_size, {"bash": "_borg_complete_file_size", "zsh": "_borg_complete_file_size"}
parser,
timestamp,
{
"bash": "_borg_complete_timestamp",
"zsh": "_borg_complete_timestamp",
"tcsh": "`_borg_complete_timestamp`",
},
)
_attach_completion(
parser,
parse_file_size,
{
"bash": "_borg_complete_file_size",
"zsh": "_borg_complete_file_size",
"tcsh": "`_borg_complete_file_size`",
},
)

# Collect all commands and help topics for "borg help" completion
Expand All @@ -694,7 +872,9 @@ def do_completion(self, args):
help_choices.extend(action.choices.keys())

help_completion_fn = "_borg_help_topics"
_attach_help_completion(parser, {"bash": help_completion_fn, "zsh": help_completion_fn})
_attach_help_completion(
parser, {"bash": help_completion_fn, "zsh": help_completion_fn, "tcsh": "`_borg_help_topics`"}
)

# Build preambles using partial_format to avoid escaping braces etc.
sort_keys = " ".join(AI_HUMAN_SORT_KEYS)
Expand Down Expand Up @@ -730,11 +910,14 @@ def do_completion(self, args):
}
bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping)
zsh_preamble = partial_format(ZSH_PREAMBLE_TMPL, mapping)
tcsh_preamble = partial_format(TCSH_PREAMBLE_TMPL, mapping)

if args.shell == "bash":
preambles = [bash_preamble]
elif args.shell == "zsh":
preambles = [zsh_preamble]
elif args.shell == "tcsh":
preambles = [tcsh_preamble]
else:
preambles = []
script = parser.get_completion_script(f"shtab-{args.shell}", preambles=preambles)
Expand Down
27 changes: 27 additions & 0 deletions src/borg/testsuite/archiver/completion_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def cmd_available(cmd):

needs_bash = pytest.mark.skipif(not cmd_available("bash --version"), reason="Bash not available")
needs_zsh = pytest.mark.skipif(not cmd_available("zsh --version"), reason="Zsh 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 @@ -57,6 +58,14 @@ def test_zsh_completion_nontrivial(archivers, request):
assert output.count("\n") > 100, f"Zsh 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))}"


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


Expand Down Expand Up @@ -90,6 +99,24 @@ def test_zsh_completion_syntax(archivers, request):
assert result.returncode == 0, f"Generated Zsh 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