diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79945d0..175cb4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,7 @@ Most of the magic lives in [`shtab/__init__.py`](./shtab/__init__.py). - `complete()` - primary API, calls shell-specific versions - `complete_bash()` - `complete_zsh()` + - `complete_tcsh()` - ... - `add_argument_to()` - convenience function for library integration - `Optional()`, `Required()`, `Choice()` - legacy helpers for advanced completion (e.g. dirs, files, `*.txt`) diff --git a/README.rst b/README.rst index 6cb5831..8157418 100644 --- a/README.rst +++ b/README.rst @@ -20,6 +20,7 @@ Features - ``bash`` - ``zsh`` + - ``tcsh`` - Supports @@ -313,7 +314,6 @@ Please do open issues & pull requests! Some ideas: - support ``fish`` - support ``powershell`` -- support ``tcsh`` See `CONTRIBUTING.md `_ diff --git a/docs/index.md b/docs/index.md index 4126b7c..2907c4d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,7 @@ - Outputs tab completion scripts for - `bash` - `zsh` + - `tcsh` - Supports - [argparse](https://docs.python.org/library/argparse) - [docopt](https://pypi.org/project/docopt) (via [argopt](https://pypi.org/project/argopt)) diff --git a/docs/use.md b/docs/use.md index 967c31b..fbd7319 100644 --- a/docs/use.md +++ b/docs/use.md @@ -7,8 +7,8 @@ There are two ways of using `shtab`: - end-users execute `shtab your_cli_app.your_parser_object` - [Library Usage](#library-usage): as a library integrated into your CLI application - adds a couple of lines to your application - - argument mode: end-users execute `your_cli_app --print-completion {bash,zsh}` - - subparser mode: end-users execute `your_cli_app completion {bash,zsh}` + - argument mode: end-users execute `your_cli_app --print-completion {bash,zsh,tcsh}` + - subparser mode: end-users execute `your_cli_app completion {bash,zsh,tcsh}` ## CLI Usage @@ -77,6 +77,27 @@ Below are various examples of enabling `shtab`'s own tab completion scripts. shtab --shell=zsh shtab.main.get_main_parser > ~/.zsh/completions/_shtab ``` +=== "tcsh" + + ```sh + shtab --shell=tcsh shtab.main.get_main_parser --error-unimportable \ + | sudo tee /etc/profile.d/shtab.completion.csh + ``` + +=== "Eager tcsh" + + There are a few options: + + ```sh + # Install locally + echo 'shtab --shell=tcsh shtab.main.get_main_parser | source /dev/stdin' \ + >> ~/.cshrc + + # Install system-wide + echo 'shtab --shell=tcsh shtab.main.get_main_parser | source /dev/stdin' \ + | sudo tee /etc/profile.d/eager-completion.csh + ``` + !!! tip See the [examples/](https://github.com/iterative/shtab/tree/master/examples) folder for more. diff --git a/examples/customcomplete.py b/examples/customcomplete.py index 2bc27e5..89e2285 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -8,7 +8,9 @@ import shtab # for completion magic -TXT_FILE = {"bash": "_shtab_greeter_compgen_TXTFiles", "zsh": "_files -g '(*.txt|*.TXT)'"} +TXT_FILE = { + "bash": "_shtab_greeter_compgen_TXTFiles", "zsh": "_files -g '(*.txt|*.TXT)'", + "tcsh": "f:*.txt"} PREAMBLE = { "bash": """ # $1=COMP_WORDS[1] @@ -17,7 +19,7 @@ compgen -f -X '!*?.txt' -- $1 compgen -f -X '!*?.TXT' -- $1 } -""", "zsh": ""} +""", "zsh": "", "tcsh": ""} def process(args): @@ -48,6 +50,8 @@ def get_main_parser(): ).complete = shtab.DIRECTORY # directory tab completion builtin shortcut + main_parser.add_argument('suffix', choices=['json', 'csv'], default='json', + help="Output format") parser.set_defaults(func=process) return main_parser diff --git a/shtab/__init__.py b/shtab/__init__.py index 5b87195..7a7240e 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -13,6 +13,7 @@ _StoreConstAction, _VersionAction, ) +from collections import defaultdict from functools import total_ordering from string import Template @@ -32,8 +33,8 @@ SUPPORTED_SHELLS = [] _SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS = { - "file": {"bash": "_shtab_compgen_files", "zsh": "_files"}, - "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/"}} + "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f"}, + "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d"}} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -580,6 +581,109 @@ def format_positional(opt): ) +@mark_completer("tcsh") +def complete_tcsh(parser, root_prefix=None, preamble="", choice_functions=None): + """ + Return tcsh syntax autocompletion script. + + See `complete` for arguments. + """ + 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 "'{}/{}/({})/'".format( + arg_type, + arg_sel, + choice_strs, + ) + elif hasattr(arg, "complete"): + complete_fn = complete2pattern(arg.complete, 'tcsh', choice_type2fn) + if complete_fn: + yield "'{}/{}/{}/'".format( + arg_type, + arg_sel, + complete_fn, + ) + + def recurse_parser(cparser, positional_idx, requirements=None): + log_prefix = '| ' * positional_idx + log.debug('%sParser @ %d', log_prefix, positional_idx) + if requirements: + log.debug('%s- Requires: %s', log_prefix, ' '.join(requirements)) + else: + requirements = [] + + for optional in cparser._get_optional_actions(): + log.debug('%s| Optional: %s', log_prefix, optional.dest) + # Mingle all optional arguments for all subparsers + for optional_str in optional.option_strings: + log.debug('%s| | %s', log_prefix, optional_str) + 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)) + + 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): + for subcmd, subparser in positional.choices.items(): + log.debug('%s| | SubParser: %s', log_prefix, subcmd) + recurse_parser(subparser, positional_idx, requirements + [subcmd]) + + recurse_parser(parser, 0) + + for idx, ndict in index_choices.items(): + if len(ndict) == 1: + # Single choice, no requirements + arg = list(ndict.values())[0] + specials.extend(get_specials(arg, 'p', str(idx))) + else: + # Multiple requirements + nlist = [] + for nn, arg in ndict.items(): + checks = [ + '[ "$cmd[{}]" == "{}" ]'.format(iidx, n) for iidx, n in enumerate(nn, start=2)] + if arg.choices: + nlist.append('( {}echo "{}" || false )'.format( + ' && '.join(checks + ['']), # Append the separator + '\\n'.join(arg.choices), + )) + + # Ugly hack + specials.append("'p@{}@`set cmd=($COMMAND_LINE); {}`@'".format( + str(idx), ' || '.join(nlist))) + + return Template("""\ +#!/usr/bin/env tcsh +# 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(optionals_double), + optionals_single_str=' '.join(optionals_single), + optionals_special_str=' \\\n '.join(specials)) + + def complete(parser, shell="bash", root_prefix=None, preamble="", choice_functions=None): """ parser : argparse.ArgumentParser diff --git a/shtab/main.py b/shtab/main.py index b668093..3af0665 100644 --- a/shtab/main.py +++ b/shtab/main.py @@ -26,12 +26,15 @@ def get_main_parser(): action="store_true", help="raise errors if `parser` is not found in $PYTHONPATH", ) + parser.add_argument("--verbose", dest="loglevel", action="store_const", default=logging.INFO, + const=logging.DEBUG, help="Log debug information") return parser def main(argv=None): parser = get_main_parser() args = parser.parse_args(argv) + logging.basicConfig(level=args.loglevel) log.debug(args) module, other_parser = args.parser.rsplit(".", 1) diff --git a/tests/test_shtab.py b/tests/test_shtab.py index 5ddcf50..721fa77 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -91,6 +91,8 @@ def test_prog_scripts(shell, caplog, capsys): assert script_py == ["complete -o filenames -F _shtab_shtab script.py"] elif shell == "zsh": assert script_py == ["#compdef script.py", "_describe 'script.py commands' _commands"] + elif shell == "tcsh": + assert script_py == ["complete script.py \\"] else: raise NotImplementedError(shell)