From 4142cdd9d3ca36889bf1a5e575b0cb7978350552 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 23 Mar 2026 21:30:22 +0100 Subject: [PATCH] improvements and fixes to the tcsh completion logic Issues: 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. Fix: It now adds completions for optional arguments that take parameters (`nargs != 0`) by appending a `=` version (e.g., `--opt=`). ```python if optional.nargs != 0: specials.extend(get_specials(optional, 'c', optional_str + '=')) ``` The logic for recursing into sub-parsers was broadened. Previously, it only recursed if there were no "requirements" (positional arguments already met). Now, it recurses as long as the choices are a dictionary, enabling better support for complex nested sub-commands. The completion script generation for sub-commands was significantly rewritten: - **Condition-based logic**: It replaced an "ugly hack" using shell `||` checks with explicit `if` conditions in `tcsh`. - **Command line parsing**: It now calculates conditions based on the length and content of the command line (`$cmd`). - **Safety Padding**: It adds a padding of empty strings (`"" "" ...`) to the `$COMMAND_LINE` before assigning it to `$cmd`. This prevents "Subscript out of range" errors when `tcsh` tries to access an index that doesn't exist yet. - **Custom `complete` Support**: It now handles arguments with a `complete` attribute (custom completion logic), converting them to `tcsh` patterns and using `eval` for function calls. Finally, it ensures the `specials` list (which stores the completion rules) contains no duplicate rules while maintaining their original order: ```python specials = list(dict.fromkeys(specials)) ``` --- shtab/__init__.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index e29a2e9..a8b09bb 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -745,13 +745,15 @@ def recurse_parser(cparser, positional_idx, requirements=None): 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 log.debug("%s| Positional #%d: %s", log_prefix, positional_idx, positional.dest) index_choices[positional_idx][tuple(requirements)] = positional - if not requirements and isinstance(positional.choices, dict): + if isinstance(positional.choices, dict): for subcmd, subparser in positional.choices.items(): log.debug("%s| | SubParser: %s", log_prefix, subcmd) recurse_parser(subparser, positional_idx, requirements + [subcmd]) @@ -767,14 +769,25 @@ def recurse_parser(cparser, positional_idx, requirements=None): # Multiple requirements 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: - checks = [f'[ "$cmd[{iidx}]" == "{n}" ]' for iidx, n in enumerate(nn, start=2)] - choices_str = "' '".join(arg.choices) - checks_str = ' && '.join(checks + [f"echo '{choices_str}'"]) - nlist.append(f"( {checks_str} || false )") - # Ugly hack - nlist_str = ' || '.join(nlist) - specials.append(f"'p@{str(idx)}@`set cmd=($COMMAND_LINE); {nlist_str}`@'") + 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: @@ -783,6 +796,9 @@ def recurse_parser(cparser, positional_idx, requirements=None): # Don't add a space after completing "--" from "-" optionals_single = ('-', '-') + # removes duplicates from list, preserves order + specials = list(dict.fromkeys(specials)) + return Template("""\ # AUTOMATICALLY GENERATED by `shtab`