From 952d70a75c823f8b3bfeede64b7028e2c9e5be89 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 18 Mar 2026 12:56:49 +0100 Subject: [PATCH] fix: improve tcsh completion support This commit fixes critical evaluation crashes in the generated tcsh completion script and significantly cleans up the Python generation logic: - monkeypatch shtab to natively support positional completion under subcommands for tcsh (e.g., `borg help `). - fix "if: Empty if." errors in tcsh by injecting array bounds checks (`$#cmd >= max_idx`) inside the monkeypatched shtab generator. - fix recursive parser crashes in tcsh by replacing unescaped nested backticks (`...`) with safe `eval` evaluations. - deduplicate tcsh fallback rules to reduce the script payload size, preventing tcsh memory buffer truncations that led to cryptic "Illegal variable name." exceptions. - no support yet for archive, aid:, and tags completion features for tcsh --- src/borg/archiver/completion_cmd.py | 199 +++++++++++++++++- .../testsuite/archiver/completion_cmd_test.py | 27 +++ 2 files changed, 218 insertions(+), 8 deletions(-) diff --git a/src/borg/archiver/completion_cmd.py b/src/borg/archiver/completion_cmd.py index 4b973fafce..f4807088bb 100644 --- a/src/borg/archiver/completion_cmd.py +++ b/src/borg/archiver/completion_cmd.py @@ -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 `). 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`.""" @@ -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 @@ -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) @@ -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) diff --git a/src/borg/testsuite/archiver/completion_cmd_test.py b/src/borg/testsuite/archiver/completion_cmd_test.py index ffa091199c..2f7fd8e190 100644 --- a/src/borg/testsuite/archiver/completion_cmd_test.py +++ b/src/borg/testsuite/archiver/completion_cmd_test.py @@ -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): @@ -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 -------------------------------------------------------- @@ -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) --------------------------