_bashcomplete.py 12 KB
import copy
import os
import re

from .core import Argument
from .core import MultiCommand
from .core import Option
from .parser import split_arg_string
from .types import Choice
from .utils import echo

try:
    from collections import abc
except ImportError:
    import collections as abc

WORDBREAK = "="

# Note, only BASH version 4.4 and later have the nosort option.
COMPLETION_SCRIPT_BASH = """
%(complete_func)s() {
    local IFS=$'\n'
    COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\
                   COMP_CWORD=$COMP_CWORD \\
                   %(autocomplete_var)s=complete $1 ) )
    return 0
}

%(complete_func)setup() {
    local COMPLETION_OPTIONS=""
    local BASH_VERSION_ARR=(${BASH_VERSION//./ })
    # Only BASH version 4.4 and later have the nosort option.
    if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] \
&& [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then
        COMPLETION_OPTIONS="-o nosort"
    fi

    complete $COMPLETION_OPTIONS -F %(complete_func)s %(script_names)s
}

%(complete_func)setup
"""

COMPLETION_SCRIPT_ZSH = """
#compdef %(script_names)s

%(complete_func)s() {
    local -a completions
    local -a completions_with_descriptions
    local -a response
    (( ! $+commands[%(script_names)s] )) && return 1

    response=("${(@f)$( env COMP_WORDS=\"${words[*]}\" \\
                        COMP_CWORD=$((CURRENT-1)) \\
                        %(autocomplete_var)s=\"complete_zsh\" \\
                        %(script_names)s )}")

    for key descr in ${(kv)response}; do
      if [[ "$descr" == "_" ]]; then
          completions+=("$key")
      else
          completions_with_descriptions+=("$key":"$descr")
      fi
    done

    if [ -n "$completions_with_descriptions" ]; then
        _describe -V unsorted completions_with_descriptions -U
    fi

    if [ -n "$completions" ]; then
        compadd -U -V unsorted -a completions
    fi
    compstate[insert]="automenu"
}

compdef %(complete_func)s %(script_names)s
"""

COMPLETION_SCRIPT_FISH = (
    "complete --no-files --command %(script_names)s --arguments"
    ' "(env %(autocomplete_var)s=complete_fish'
    " COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t)"
    ' %(script_names)s)"'
)

_completion_scripts = {
    "bash": COMPLETION_SCRIPT_BASH,
    "zsh": COMPLETION_SCRIPT_ZSH,
    "fish": COMPLETION_SCRIPT_FISH,
}

_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]")


def get_completion_script(prog_name, complete_var, shell):
    cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_"))
    script = _completion_scripts.get(shell, COMPLETION_SCRIPT_BASH)
    return (
        script
        % {
            "complete_func": "_{}_completion".format(cf_name),
            "script_names": prog_name,
            "autocomplete_var": complete_var,
        }
    ).strip() + ";"


def resolve_ctx(cli, prog_name, args):
    """Parse into a hierarchy of contexts. Contexts are connected
    through the parent variable.

    :param cli: command definition
    :param prog_name: the program that is running
    :param args: full list of args
    :return: the final context/command parsed
    """
    ctx = cli.make_context(prog_name, args, resilient_parsing=True)
    args = ctx.protected_args + ctx.args
    while args:
        if isinstance(ctx.command, MultiCommand):
            if not ctx.command.chain:
                cmd_name, cmd, args = ctx.command.resolve_command(ctx, args)
                if cmd is None:
                    return ctx
                ctx = cmd.make_context(
                    cmd_name, args, parent=ctx, resilient_parsing=True
                )
                args = ctx.protected_args + ctx.args
            else:
                # Walk chained subcommand contexts saving the last one.
                while args:
                    cmd_name, cmd, args = ctx.command.resolve_command(ctx, args)
                    if cmd is None:
                        return ctx
                    sub_ctx = cmd.make_context(
                        cmd_name,
                        args,
                        parent=ctx,
                        allow_extra_args=True,
                        allow_interspersed_args=False,
                        resilient_parsing=True,
                    )
                    args = sub_ctx.args
                ctx = sub_ctx
                args = sub_ctx.protected_args + sub_ctx.args
        else:
            break
    return ctx


def start_of_option(param_str):
    """
    :param param_str: param_str to check
    :return: whether or not this is the start of an option declaration
        (i.e. starts "-" or "--")
    """
    return param_str and param_str[:1] == "-"


def is_incomplete_option(all_args, cmd_param):
    """
    :param all_args: the full original list of args supplied
    :param cmd_param: the current command paramter
    :return: whether or not the last option declaration (i.e. starts
        "-" or "--") is incomplete and corresponds to this cmd_param. In
        other words whether this cmd_param option can still accept
        values
    """
    if not isinstance(cmd_param, Option):
        return False
    if cmd_param.is_flag:
        return False
    last_option = None
    for index, arg_str in enumerate(
        reversed([arg for arg in all_args if arg != WORDBREAK])
    ):
        if index + 1 > cmd_param.nargs:
            break
        if start_of_option(arg_str):
            last_option = arg_str

    return True if last_option and last_option in cmd_param.opts else False


def is_incomplete_argument(current_params, cmd_param):
    """
    :param current_params: the current params and values for this
        argument as already entered
    :param cmd_param: the current command parameter
    :return: whether or not the last argument is incomplete and
        corresponds to this cmd_param. In other words whether or not the
        this cmd_param argument can still accept values
    """
    if not isinstance(cmd_param, Argument):
        return False
    current_param_values = current_params[cmd_param.name]
    if current_param_values is None:
        return True
    if cmd_param.nargs == -1:
        return True
    if (
        isinstance(current_param_values, abc.Iterable)
        and cmd_param.nargs > 1
        and len(current_param_values) < cmd_param.nargs
    ):
        return True
    return False


def get_user_autocompletions(ctx, args, incomplete, cmd_param):
    """
    :param ctx: context associated with the parsed command
    :param args: full list of args
    :param incomplete: the incomplete text to autocomplete
    :param cmd_param: command definition
    :return: all the possible user-specified completions for the param
    """
    results = []
    if isinstance(cmd_param.type, Choice):
        # Choices don't support descriptions.
        results = [
            (c, None) for c in cmd_param.type.choices if str(c).startswith(incomplete)
        ]
    elif cmd_param.autocompletion is not None:
        dynamic_completions = cmd_param.autocompletion(
            ctx=ctx, args=args, incomplete=incomplete
        )
        results = [
            c if isinstance(c, tuple) else (c, None) for c in dynamic_completions
        ]
    return results


def get_visible_commands_starting_with(ctx, starts_with):
    """
    :param ctx: context associated with the parsed command
    :starts_with: string that visible commands must start with.
    :return: all visible (not hidden) commands that start with starts_with.
    """
    for c in ctx.command.list_commands(ctx):
        if c.startswith(starts_with):
            command = ctx.command.get_command(ctx, c)
            if not command.hidden:
                yield command


def add_subcommand_completions(ctx, incomplete, completions_out):
    # Add subcommand completions.
    if isinstance(ctx.command, MultiCommand):
        completions_out.extend(
            [
                (c.name, c.get_short_help_str())
                for c in get_visible_commands_starting_with(ctx, incomplete)
            ]
        )

    # Walk up the context list and add any other completion
    # possibilities from chained commands
    while ctx.parent is not None:
        ctx = ctx.parent
        if isinstance(ctx.command, MultiCommand) and ctx.command.chain:
            remaining_commands = [
                c
                for c in get_visible_commands_starting_with(ctx, incomplete)
                if c.name not in ctx.protected_args
            ]
            completions_out.extend(
                [(c.name, c.get_short_help_str()) for c in remaining_commands]
            )


def get_choices(cli, prog_name, args, incomplete):
    """
    :param cli: command definition
    :param prog_name: the program that is running
    :param args: full list of args
    :param incomplete: the incomplete text to autocomplete
    :return: all the possible completions for the incomplete
    """
    all_args = copy.deepcopy(args)

    ctx = resolve_ctx(cli, prog_name, args)
    if ctx is None:
        return []

    has_double_dash = "--" in all_args

    # In newer versions of bash long opts with '='s are partitioned, but
    # it's easier to parse without the '='
    if start_of_option(incomplete) and WORDBREAK in incomplete:
        partition_incomplete = incomplete.partition(WORDBREAK)
        all_args.append(partition_incomplete[0])
        incomplete = partition_incomplete[2]
    elif incomplete == WORDBREAK:
        incomplete = ""

    completions = []
    if not has_double_dash and start_of_option(incomplete):
        # completions for partial options
        for param in ctx.command.params:
            if isinstance(param, Option) and not param.hidden:
                param_opts = [
                    param_opt
                    for param_opt in param.opts + param.secondary_opts
                    if param_opt not in all_args or param.multiple
                ]
                completions.extend(
                    [(o, param.help) for o in param_opts if o.startswith(incomplete)]
                )
        return completions
    # completion for option values from user supplied values
    for param in ctx.command.params:
        if is_incomplete_option(all_args, param):
            return get_user_autocompletions(ctx, all_args, incomplete, param)
    # completion for argument values from user supplied values
    for param in ctx.command.params:
        if is_incomplete_argument(ctx.params, param):
            return get_user_autocompletions(ctx, all_args, incomplete, param)

    add_subcommand_completions(ctx, incomplete, completions)
    # Sort before returning so that proper ordering can be enforced in custom types.
    return sorted(completions)


def do_complete(cli, prog_name, include_descriptions):
    cwords = split_arg_string(os.environ["COMP_WORDS"])
    cword = int(os.environ["COMP_CWORD"])
    args = cwords[1:cword]
    try:
        incomplete = cwords[cword]
    except IndexError:
        incomplete = ""

    for item in get_choices(cli, prog_name, args, incomplete):
        echo(item[0])
        if include_descriptions:
            # ZSH has trouble dealing with empty array parameters when
            # returned from commands, use '_' to indicate no description
            # is present.
            echo(item[1] if item[1] else "_")

    return True


def do_complete_fish(cli, prog_name):
    cwords = split_arg_string(os.environ["COMP_WORDS"])
    incomplete = os.environ["COMP_CWORD"]
    args = cwords[1:]

    for item in get_choices(cli, prog_name, args, incomplete):
        if item[1]:
            echo("{arg}\t{desc}".format(arg=item[0], desc=item[1]))
        else:
            echo(item[0])

    return True


def bashcomplete(cli, prog_name, complete_var, complete_instr):
    if "_" in complete_instr:
        command, shell = complete_instr.split("_", 1)
    else:
        command = complete_instr
        shell = "bash"

    if command == "source":
        echo(get_completion_script(prog_name, complete_var, shell))
        return True
    elif command == "complete":
        if shell == "fish":
            return do_complete_fish(cli, prog_name)
        elif shell in {"bash", "zsh"}:
            return do_complete(cli, prog_name, shell == "zsh")

    return False