Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supports fish shell #174

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
170 changes: 168 additions & 2 deletions shtab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
SUPPORTED_SHELLS: List[str] = []
_SUPPORTED_COMPLETERS = {}
CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = {
"file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f"},
"directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d"}}
"file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", "fish": None},
soraxas marked this conversation as resolved.
Show resolved Hide resolved
"directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", "fish": None}}
FILE = CHOICE_FUNCTIONS["file"]
DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"]
FLAG_OPTION = (
Expand Down Expand Up @@ -784,6 +784,172 @@
optionals_special_str=' \\\n '.join(specials))


def get_fish_commands(root_parser, choice_functions=None):
"""
Recursive subcommand parser traversal, returning lists of information on
commands (formatted for output to the completions script).
printing fish syntax.
"""
choice_type2fn = {k: v["fish"] for k, v in CHOICE_FUNCTIONS.items()}
if choice_functions:
choice_type2fn.update(choice_functions)

Check warning on line 795 in shtab/__init__.py

View check run for this annotation

Codecov / codecov/patch

shtab/__init__.py#L795

Added line #L795 was not covered by tests

def get_option_strings(parser):
"""Flattened list of all `parser`'s option strings."""
res = []
for opt in parser._get_optional_actions():
line = []
if opt.help == SUPPRESS:
continue

Check warning on line 803 in shtab/__init__.py

View check run for this annotation

Codecov / codecov/patch

shtab/__init__.py#L803

Added line #L803 was not covered by tests
short_opt, long_opt = None, None
for o in opt.option_strings:
if o.startswith("--"):
long_opt = o[2:]
elif o.startswith("-") and len(o) == 2:
short_opt = o[1:]
if short_opt:
line.extend(["-s", short_opt])
if long_opt:
line.extend(["-l", long_opt])
if opt.help:
line.extend(["-d", opt.help])

complete_args = None
if opt.choices:
complete_args = []
for c in opt.choices:
complete_args.append(c)
complete_args.append(opt.dest)
res.append((line, complete_args))
return res

option_strings = []
choices = []

def recurse(parser, using_cmd=()):
"""recurse through subparsers, appending to the return lists"""
def _escape_and_quote_if_needed(word, quote="'", escape_cmd=False):
word = word.replace("'", r"\'") # escape
if escape_cmd:
word = word.replace("(", r"\(") # escape
word = word.replace(")", r"\)") # escape
if " " in word and not (word[0] in ('"', "'") and word[-1] in ('"', "'")):
word = f"{quote}{word}{quote}"
return word

def format_complete_command(
args=None,
complete_args=None,
complete_args_with_exclusive=True,
desc=None,
):
complete_command = ["complete", "-c", root_parser.prog]
if using_cmd:
complete_command.extend([
"-n",
f"__fish_seen_subcommand_from {' '.join(using_cmd)}",])
else:
complete_command.extend(["-n", f"__fish_use_subcommand"])
if args:
complete_command.extend(args)
if desc:
complete_command.extend(["-d", "command" if desc == SUPPRESS else desc])

Check warning on line 856 in shtab/__init__.py

View check run for this annotation

Codecov / codecov/patch

shtab/__init__.py#L856

Added line #L856 was not covered by tests
# add quote if needed
complete_command = list(map(_escape_and_quote_if_needed, complete_command))
# the following should not be quoted as a whole
# (printf - -"%s\n" "commands configs repo switches")
if complete_args:
complete_command.extend([
"-a",
r'{q}(printf "%s\t%s\n" {args}){q}'.format(
q="'",
args=" ".join(
_escape_and_quote_if_needed(a, quote='"', escape_cmd=True)
for a in complete_args),
),])
if complete_args_with_exclusive:
complete_command.append("-x")
return " ".join(complete_command)

# positional arguments
discovered_subparsers = []
for positional in parser._get_positional_actions():
if positional.help == SUPPRESS:
continue

Check warning on line 878 in shtab/__init__.py

View check run for this annotation

Codecov / codecov/patch

shtab/__init__.py#L878

Added line #L878 was not covered by tests

if positional.choices:
# map choice of action to their help msg
choices_to_action = {
v.dest: v.help
for v in getattr(positional, "_choices_actions", [])}

this_positional_choices = []
for choice in positional.choices:

if isinstance(choice, Choice):
# not supported
pass

Check warning on line 891 in shtab/__init__.py

View check run for this annotation

Codecov / codecov/patch

shtab/__init__.py#L891

Added line #L891 was not covered by tests
elif isinstance(positional.choices, dict):
# subparser, so append to list of subparsers & recurse
log.debug("subcommand:%s", choice)
public_cmds = get_public_subcommands(positional)
if choice in public_cmds:
# ic(choice)
discovered_subparsers.append(str(choice))
this_positional_choices.extend(
(choice, choices_to_action.get(choice, "")))
recurse(
positional.choices[choice],
using_cmd=using_cmd + (choice,),
)
else:
log.debug("skip:subcommand:%s", choice)

Check warning on line 906 in shtab/__init__.py

View check run for this annotation

Codecov / codecov/patch

shtab/__init__.py#L906

Added line #L906 was not covered by tests
else:
# simple choice
this_positional_choices.extend((choice, choices_to_action.get(choice, "")))

if this_positional_choices:
choices.append(
format_complete_command(complete_args=this_positional_choices,
# desc=positional.dest,
))

# optional arguments
option_strings.extend([
format_complete_command(ret[0], complete_args=ret[1])
for ret in get_option_strings(parser)])

recurse(root_parser)
return option_strings, choices


@mark_completer("fish")
def complete_fish(parser, root_prefix=None, preamble="", choice_functions=None):
"""
Returns fish syntax autocompletion script.

See `complete` for arguments.
"""

option_strings, choices = get_fish_commands(parser, choice_functions=choice_functions)

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

${option_strings}

${choices}\
\
${preamble}
""").safe_substitute(
option_strings="\n".join(option_strings),
choices="\n".join(choices),
preamble=("\n# Custom Preamble\n" + preamble +
"\n# End Custom Preamble\n" if preamble else ""),
prog=parser.prog,
)


def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: Opt[str] = None,
preamble: Union[str, Dict[str, str]] = "", choice_functions: Opt[Any] = None) -> str:
"""
Expand Down
14 changes: 13 additions & 1 deletion tests/test_shtab.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def test_main_self_completion(shell, caplog, capsys):
assert not captured.err
expected = {
"bash": "complete -o filenames -F _shtab_shtab shtab", "zsh": "_shtab_shtab_commands()",
"tcsh": "complete shtab"}
"tcsh": "complete shtab", "fish": "complete -c shtab"}
assert expected[shell] in captured.out

assert not caplog.record_tuples
Expand Down Expand Up @@ -111,6 +111,18 @@ def test_prog_scripts(shell, caplog, capsys):
"compdef _shtab_shtab -N script.py"]
elif shell == "tcsh":
assert script_py == ["complete script.py \\"]
elif shell == "fish":
assert script_py == [
"complete -c script.py -n __fish_use_subcommand -s h -l help -d 'show this help message and exit'",
"complete -c script.py -n __fish_use_subcommand -l version -d 'show program\\'s version number and exit'",
"complete -c script.py -n __fish_use_subcommand -s s -l shell -a '(printf \"%s\\t%s\\n\" bash shell zsh shell tcsh shell fish shell)' -x",
"complete -c script.py -n __fish_use_subcommand -l prefix -d 'prepended to generated functions to avoid clashes'",
"complete -c script.py -n __fish_use_subcommand -l preamble -d 'prepended to generated script'",
"complete -c script.py -n __fish_use_subcommand -l prog -d 'custom program name (overrides `parser.prog`)'",
"complete -c script.py -n __fish_use_subcommand -s u -l error-unimportable -d 'raise errors if `parser` is not found in $PYTHONPATH'",
"complete -c script.py -n __fish_use_subcommand -l verbose -d 'Log debug information'",
"complete -c script.py -n __fish_use_subcommand -l print-own-completion -d 'print shtab\\'s own completion' -a '(printf \"%s\\t%s\\n\" bash print_own_completion zsh print_own_completion tcsh print_own_completion fish print_own_completion)' -x",
]
else:
raise NotImplementedError(shell)

Expand Down