Skip to content

Commit 7cf6095

Browse files
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 <topic>`). - 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
1 parent 6bc4146 commit 7cf6095

2 files changed

Lines changed: 218 additions & 8 deletions

File tree

src/borg/archiver/completion_cmd.py

Lines changed: 191 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,144 @@
627627
"""
628628

629629

630+
TCSH_PREAMBLE_TMPL = r"""
631+
# Dynamic completion helpers for tcsh
632+
633+
alias _borg_complete_timestamp 'date +"%Y-%m-%dT%H:%M:%S"'
634+
635+
636+
alias _borg_complete_sortby "echo {SORT_KEYS}"
637+
alias _borg_complete_filescachemode "echo {FCM_KEYS}"
638+
alias _borg_help_topics "echo {HELP_CHOICES}"
639+
alias _borg_complete_compression_spec "echo {COMP_SPEC_CHOICES}"
640+
alias _borg_complete_chunker_params "echo {CHUNKER_PARAMS_CHOICES}"
641+
alias _borg_complete_relative_time "echo {RELATIVE_TIME_CHOICES}"
642+
alias _borg_complete_file_size "echo {FILE_SIZE_CHOICES}"
643+
"""
644+
645+
646+
def _monkeypatch_shtab():
647+
"""
648+
Monkeypatches shtab's tcsh completion logic to fix severe parsing issues and add missing features.
649+
650+
1. Subcommand Positional Completion: shtab lacks native support for auto-completing positional
651+
arguments that belong to subcommands in tcsh (e.g., `borg help <topic>`). This builds a
652+
conditional evaluation structure (`if ( $#cmd >= X && ... )`) to support them.
653+
2. Subshell Array Indexing Fix: `tcsh` aggressively evaluates array indices like `$cmd[2]` even
654+
if the array is smaller than the requested index, causing "if: Empty if." errors. Added
655+
explicit bounds checking (`$#cmd >= max_idx`).
656+
3. Nested Subshell Safety: Standard shtab nests subshells using backticks which causes recursive
657+
parsing crashes in tcsh. Replaced with safe `eval` usage.
658+
"""
659+
import shtab
660+
from shtab import CHOICE_FUNCTIONS, complete2pattern
661+
from collections import defaultdict
662+
from argparse import SUPPRESS
663+
from string import Template
664+
665+
def patched_complete_tcsh(parser, root_prefix=None, preamble="", choice_functions=None):
666+
optionals_single = set()
667+
optionals_double = set()
668+
specials = []
669+
index_choices = defaultdict(dict)
670+
671+
choice_type2fn = {k: v["tcsh"] for k, v in CHOICE_FUNCTIONS.items()}
672+
673+
if choice_functions:
674+
choice_type2fn.update(choice_functions)
675+
676+
def get_specials(arg, arg_type, arg_sel):
677+
if arg.choices:
678+
choice_strs = " ".join(map(str, arg.choices))
679+
yield f"'{arg_type}/{arg_sel}/({choice_strs})/'"
680+
elif hasattr(arg, "complete"):
681+
complete_fn = complete2pattern(arg.complete, "tcsh", choice_type2fn)
682+
if complete_fn:
683+
yield f"'{arg_type}/{arg_sel}/{complete_fn}/'"
684+
685+
def recurse_parser(cparser, positional_idx, requirements=None):
686+
if requirements is None:
687+
requirements = []
688+
689+
for optional in cparser._get_optional_actions():
690+
if optional.help != SUPPRESS:
691+
for optional_str in optional.option_strings:
692+
if optional_str.startswith("--"):
693+
optionals_double.add(optional_str[2:])
694+
elif optional_str.startswith("-"):
695+
optionals_single.add(optional_str[1:])
696+
specials.extend(get_specials(optional, "n", optional_str))
697+
if optional.nargs != 0:
698+
specials.extend(get_specials(optional, "c", optional_str + "="))
699+
700+
for positional in cparser._get_positional_actions():
701+
if positional.help != SUPPRESS:
702+
positional_idx += 1
703+
index_choices[positional_idx][tuple(requirements)] = positional
704+
if isinstance(positional.choices, dict):
705+
for subcmd, subparser in positional.choices.items():
706+
recurse_parser(subparser, positional_idx, requirements + [subcmd])
707+
708+
recurse_parser(parser, 0)
709+
710+
for idx, ndict in index_choices.items():
711+
if len(ndict) == 1:
712+
arg = list(ndict.values())[0]
713+
specials.extend(get_specials(arg, "p", str(idx)))
714+
else:
715+
nlist = []
716+
for nn, arg in ndict.items():
717+
max_idx = len(nn) + 1
718+
checks = [f'("$cmd[{iidx}]" == "{n}")' for iidx, n in enumerate(nn, start=2)]
719+
condition = f"$#cmd >= {max_idx} && " + " && ".join(checks)
720+
if arg.choices:
721+
choices_str = " ".join(map(str, arg.choices))
722+
nlist.append(f"if ( {condition} ) echo {choices_str}")
723+
elif hasattr(arg, "complete"):
724+
complete_fn = complete2pattern(arg.complete, "tcsh", choice_type2fn)
725+
if complete_fn:
726+
if complete_fn.startswith("`") and complete_fn.endswith("`"):
727+
func_name = complete_fn.strip("`")
728+
nlist.append(f"if ( {condition} ) eval {func_name}")
729+
else:
730+
nlist.append(f"if ( {condition} ) {complete_fn}")
731+
if nlist:
732+
nlist_str = "; ".join(nlist)
733+
padding = '"" "" "" "" "" "" "" "" ""'
734+
specials.append(f"'p@{str(idx)}@`set cmd=(\"$COMMAND_LINE\" {padding}); {nlist_str}`@'")
735+
736+
if optionals_double:
737+
if optionals_single:
738+
optionals_single.add("-")
739+
else:
740+
optionals_single = ("-", "-")
741+
742+
specials = list(dict.fromkeys(specials))
743+
744+
return Template(
745+
"""\
746+
# AUTOMATICALLY GENERATED by `shtab`
747+
748+
${preamble}
749+
750+
complete ${prog} \\
751+
'c/--/(${optionals_double_str})/' \\
752+
'c/-/(${optionals_single_str})/' \\
753+
${optionals_special_str} \\
754+
'p/*/()/'"""
755+
).safe_substitute(
756+
preamble=("\n# Custom Preamble\n" + preamble + "\n# End Custom Preamble\n" if preamble else ""),
757+
root_prefix=root_prefix,
758+
prog=parser.prog,
759+
optionals_double_str=" ".join(sorted(optionals_double)),
760+
optionals_single_str=" ".join(sorted(optionals_single)),
761+
optionals_special_str=" \\\n ".join(specials),
762+
)
763+
764+
shtab.complete_tcsh = patched_complete_tcsh
765+
shtab._SUPPORTED_COMPLETERS["tcsh"] = patched_complete_tcsh
766+
767+
630768
def _attach_completion(parser: ArgumentParser, type_class, completion_dict: dict):
631769
"""Tag all arguments with type `type_class` with completion choices from `completion_dict`."""
632770

@@ -659,32 +797,72 @@ def do_completion(self, args):
659797
# adds dynamic completion for archive IDs with the aid: prefix for all ARCHIVE
660798
# arguments (identified by archivename_validator). It reuses `borg repo-list`
661799
# to enumerate archives and does not introduce any new commands or caching.
800+
_monkeypatch_shtab()
662801
parser = self.build_parser()
663802
_attach_completion(
664803
parser, archivename_validator, {"bash": "_borg_complete_archive", "zsh": "_borg_complete_archive"}
665804
)
666-
_attach_completion(parser, SortBySpec, {"bash": "_borg_complete_sortby", "zsh": "_borg_complete_sortby"})
805+
667806
_attach_completion(
668-
parser, FilesCacheMode, {"bash": "_borg_complete_filescachemode", "zsh": "_borg_complete_filescachemode"}
807+
parser,
808+
SortBySpec,
809+
{"bash": "_borg_complete_sortby", "zsh": "_borg_complete_sortby", "tcsh": "`_borg_complete_sortby`"},
810+
)
811+
_attach_completion(
812+
parser,
813+
FilesCacheMode,
814+
{
815+
"bash": "_borg_complete_filescachemode",
816+
"zsh": "_borg_complete_filescachemode",
817+
"tcsh": "`_borg_complete_filescachemode`",
818+
},
669819
)
670820
_attach_completion(
671821
parser,
672822
CompressionSpec,
673-
{"bash": "_borg_complete_compression_spec", "zsh": "_borg_complete_compression_spec"},
823+
{
824+
"bash": "_borg_complete_compression_spec",
825+
"zsh": "_borg_complete_compression_spec",
826+
"tcsh": "`_borg_complete_compression_spec`",
827+
},
674828
)
675829
_attach_completion(parser, PathSpec, shtab.DIRECTORY)
676830
_attach_completion(
677-
parser, ChunkerParams, {"bash": "_borg_complete_chunker_params", "zsh": "_borg_complete_chunker_params"}
831+
parser,
832+
ChunkerParams,
833+
{
834+
"bash": "_borg_complete_chunker_params",
835+
"zsh": "_borg_complete_chunker_params",
836+
"tcsh": "`_borg_complete_chunker_params`",
837+
},
678838
)
679839
_attach_completion(parser, tag_validator, {"bash": "_borg_complete_tags", "zsh": "_borg_complete_tags"})
680840
_attach_completion(
681841
parser,
682842
relative_time_marker_validator,
683-
{"bash": "_borg_complete_relative_time", "zsh": "_borg_complete_relative_time"},
843+
{
844+
"bash": "_borg_complete_relative_time",
845+
"zsh": "_borg_complete_relative_time",
846+
"tcsh": "`_borg_complete_relative_time`",
847+
},
684848
)
685-
_attach_completion(parser, timestamp, {"bash": "_borg_complete_timestamp", "zsh": "_borg_complete_timestamp"})
686849
_attach_completion(
687-
parser, parse_file_size, {"bash": "_borg_complete_file_size", "zsh": "_borg_complete_file_size"}
850+
parser,
851+
timestamp,
852+
{
853+
"bash": "_borg_complete_timestamp",
854+
"zsh": "_borg_complete_timestamp",
855+
"tcsh": "`_borg_complete_timestamp`",
856+
},
857+
)
858+
_attach_completion(
859+
parser,
860+
parse_file_size,
861+
{
862+
"bash": "_borg_complete_file_size",
863+
"zsh": "_borg_complete_file_size",
864+
"tcsh": "`_borg_complete_file_size`",
865+
},
688866
)
689867

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

696874
help_completion_fn = "_borg_help_topics"
697-
_attach_help_completion(parser, {"bash": help_completion_fn, "zsh": help_completion_fn})
875+
_attach_help_completion(
876+
parser, {"bash": help_completion_fn, "zsh": help_completion_fn, "tcsh": "`_borg_help_topics`"}
877+
)
698878

699879
# Build preambles using partial_format to avoid escaping braces etc.
700880
sort_keys = " ".join(AI_HUMAN_SORT_KEYS)
@@ -730,11 +910,14 @@ def do_completion(self, args):
730910
}
731911
bash_preamble = partial_format(BASH_PREAMBLE_TMPL, mapping)
732912
zsh_preamble = partial_format(ZSH_PREAMBLE_TMPL, mapping)
913+
tcsh_preamble = partial_format(TCSH_PREAMBLE_TMPL, mapping)
733914

734915
if args.shell == "bash":
735916
preambles = [bash_preamble]
736917
elif args.shell == "zsh":
737918
preambles = [zsh_preamble]
919+
elif args.shell == "tcsh":
920+
preambles = [tcsh_preamble]
738921
else:
739922
preambles = []
740923
script = parser.get_completion_script(f"shtab-{args.shell}", preambles=preambles)

src/borg/testsuite/archiver/completion_cmd_test.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def cmd_available(cmd):
2222

2323
needs_bash = pytest.mark.skipif(not cmd_available("bash --version"), reason="Bash not available")
2424
needs_zsh = pytest.mark.skipif(not cmd_available("zsh --version"), reason="Zsh not available")
25+
needs_tcsh = pytest.mark.skipif(not cmd_available("tcsh --version"), reason="Tcsh not available")
2526

2627

2728
def _run_bash_completion_fn(completion_script, setup_code):
@@ -57,6 +58,14 @@ def test_zsh_completion_nontrivial(archivers, request):
5758
assert output.count("\n") > 100, f"Zsh completion suspiciously few lines: {output.count(chr(10))}"
5859

5960

61+
def test_tcsh_completion_nontrivial(archivers, request):
62+
"""Verify the generated Tcsh completion is non-trivially sized."""
63+
archiver = request.getfixturevalue(archivers)
64+
output = cmd(archiver, "completion", "tcsh")
65+
assert len(output) > 1000, f"Tcsh completion suspiciously small: {len(output)} chars"
66+
assert output.count("\n") > 20, f"Tcsh completion suspiciously few lines: {output.count(chr(10))}"
67+
68+
6069
# -- syntax validation --------------------------------------------------------
6170

6271

@@ -90,6 +99,24 @@ def test_zsh_completion_syntax(archivers, request):
9099
assert result.returncode == 0, f"Generated Zsh completion has syntax errors: {result.stderr.decode()}"
91100

92101

102+
@needs_tcsh
103+
def test_tcsh_completion_syntax(archivers, request):
104+
"""Verify the generated Tcsh completion script has valid syntax."""
105+
archiver = request.getfixturevalue(archivers)
106+
output = cmd(archiver, "completion", "tcsh")
107+
# tcsh doesn't have -n for syntax check like bash/zsh, but we can try to source it
108+
# and see if it fails. 'tcsh -f -c "source path"'
109+
with tempfile.NamedTemporaryFile(mode="w", suffix=".tcsh", delete=False) as f:
110+
f.write(output)
111+
script_path = f.name
112+
try:
113+
# -f: fast start (don't resource .tcshrc)
114+
result = subprocess.run(["tcsh", "-f", "-c", f"source {script_path}"], capture_output=True)
115+
finally:
116+
os.unlink(script_path)
117+
assert result.returncode == 0, f"Generated Tcsh completion has errors: {result.stderr.decode()}"
118+
119+
93120
# -- borg-specific preamble function behavior (bash) --------------------------
94121

95122

0 commit comments

Comments
 (0)