1#
2#  Copyright (c) 2014 by Armin Ronacher.
3#  Copyright (C) 2016 Codethink Limited
4#
5#  This program is free software; you can redistribute it and/or
6#  modify it under the terms of the GNU Lesser General Public
7#  License as published by the Free Software Foundation; either
8#  version 2 of the License, or (at your option) any later version.
9#
10#  This library is distributed in the hope that it will be useful,
11#  but WITHOUT ANY WARRANTY; without even the implied warranty of
12#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the GNU
13#  Lesser General Public License for more details.
14#
15#  You should have received a copy of the GNU Lesser General Public
16#  License along with this library. If not, see <http://www.gnu.org/licenses/>.
17#
18#  This module was forked from the python click library, Included
19#  original copyright notice from the Click library and following disclaimer
20#  as per their LICENSE requirements.
21#
22#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
23#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24#  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
25#  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
26#  OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
27#  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
28#  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
29#  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
30#  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
31#  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32#  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33#
34import collections
35import copy
36import os
37
38import click
39from click.core import MultiCommand, Option, Argument
40from click.parser import split_arg_string
41
42WORDBREAK = '='
43
44COMPLETION_SCRIPT = '''
45%(complete_func)s() {
46    local IFS=$'\n'
47    COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\
48                   COMP_CWORD=$COMP_CWORD \\
49                   %(autocomplete_var)s=complete $1 ) )
50    return 0
51}
52
53complete -F %(complete_func)s -o nospace %(script_names)s
54'''
55
56
57# An exception for our custom completion handler to
58# indicate that it does not want to handle completion
59# for this parameter
60#
61class CompleteUnhandled(Exception):
62    pass
63
64
65def complete_path(path_type, incomplete, base_directory='.'):
66    """Helper method for implementing the completions() method
67    for File and Path parameter types.
68    """
69
70    # Try listing the files in the relative or absolute path
71    # specified in `incomplete` minus the last path component,
72    # otherwise list files starting from the current working directory.
73    entries = []
74    base_path = ''
75
76    # This is getting a bit messy
77    listed_base_directory = False
78
79    if os.path.sep in incomplete:
80        split = incomplete.rsplit(os.path.sep, 1)
81        base_path = split[0]
82
83        # If there was nothing on the left of the last separator,
84        # we are completing files in the filesystem root
85        base_path = os.path.join(base_directory, base_path)
86    else:
87        incomplete_base_path = os.path.join(base_directory, incomplete)
88        if os.path.isdir(incomplete_base_path):
89            base_path = incomplete_base_path
90
91    try:
92        if base_path:
93            if os.path.isdir(base_path):
94                entries = [os.path.join(base_path, e) for e in os.listdir(base_path)]
95        else:
96            entries = os.listdir(base_directory)
97            listed_base_directory = True
98    except OSError:
99        # If for any reason the os reports an error from os.listdir(), just
100        # ignore this and avoid a stack trace
101        pass
102
103    base_directory_slash = base_directory
104    if not base_directory_slash.endswith(os.sep):
105        base_directory_slash += os.sep
106    base_directory_len = len(base_directory_slash)
107
108    def entry_is_dir(entry):
109        if listed_base_directory:
110            entry = os.path.join(base_directory, entry)
111        return os.path.isdir(entry)
112
113    def fix_path(path):
114
115        # Append slashes to any entries which are directories, or
116        # spaces for other files since they cannot be further completed
117        if entry_is_dir(path) and not path.endswith(os.sep):
118            path = path + os.sep
119        else:
120            path = path + " "
121
122        # Remove the artificial leading path portion which
123        # may have been prepended for search purposes.
124        if path.startswith(base_directory_slash):
125            path = path[base_directory_len:]
126
127        return path
128
129    return [
130        # Return an appropriate path for each entry
131        fix_path(e) for e in sorted(entries)
132
133        # Filter out non directory elements when searching for a directory,
134        # the opposite is fine, however.
135        if not (path_type == 'Directory' and not entry_is_dir(e))
136    ]
137
138
139# Instead of delegating completions to the param type,
140# hard code all of buildstream's completions here.
141#
142# This whole module should be removed in favor of more
143# generic code in click once this issue is resolved:
144#   https://github.com/pallets/click/issues/780
145#
146def get_param_type_completion(param_type, incomplete):
147
148    if isinstance(param_type, click.Choice):
149        return [c + " " for c in param_type.choices]
150    elif isinstance(param_type, click.File):
151        return complete_path("File", incomplete)
152    elif isinstance(param_type, click.Path):
153        return complete_path(param_type.path_type, incomplete)
154
155    return []
156
157
158def resolve_ctx(cli, prog_name, args):
159    """
160    Parse into a hierarchy of contexts. Contexts are connected through the parent variable.
161    :param cli: command definition
162    :param prog_name: the program that is running
163    :param args: full list of args typed before the incomplete arg
164    :return: the final context/command parsed
165    """
166    ctx = cli.make_context(prog_name, args, resilient_parsing=True)
167    args_remaining = ctx.protected_args + ctx.args
168    while ctx is not None and args_remaining:
169        if isinstance(ctx.command, MultiCommand):
170            cmd = ctx.command.get_command(ctx, args_remaining[0])
171            if cmd is None:
172                return None
173            ctx = cmd.make_context(args_remaining[0], args_remaining[1:], parent=ctx, resilient_parsing=True)
174            args_remaining = ctx.protected_args + ctx.args
175        else:
176            ctx = ctx.parent
177
178    return ctx
179
180
181def start_of_option(param_str):
182    """
183    :param param_str: param_str to check
184    :return: whether or not this is the start of an option declaration (i.e. starts "-" or "--")
185    """
186    return param_str and param_str[:1] == '-'
187
188
189def is_incomplete_option(all_args, cmd_param):
190    """
191    :param all_args: the full original list of args supplied
192    :param cmd_param: the current command paramter
193    :return: whether or not the last option declaration (i.e. starts "-" or "--") is incomplete and
194    corresponds to this cmd_param. In other words whether this cmd_param option can still accept
195    values
196    """
197    if cmd_param.is_flag:
198        return False
199    last_option = None
200    for index, arg_str in enumerate(reversed([arg for arg in all_args if arg != WORDBREAK])):
201        if index + 1 > cmd_param.nargs:
202            break
203        if start_of_option(arg_str):
204            last_option = arg_str
205
206    return True if last_option and last_option in cmd_param.opts else False
207
208
209def is_incomplete_argument(current_params, cmd_param):
210    """
211    :param current_params: the current params and values for this argument as already entered
212    :param cmd_param: the current command parameter
213    :return: whether or not the last argument is incomplete and corresponds to this cmd_param. In
214    other words whether or not the this cmd_param argument can still accept values
215    """
216    current_param_values = current_params[cmd_param.name]
217    if current_param_values is None:
218        return True
219    if cmd_param.nargs == -1:
220        return True
221    if isinstance(current_param_values, collections.Iterable) \
222            and cmd_param.nargs > 1 and len(current_param_values) < cmd_param.nargs:
223        return True
224    return False
225
226
227def get_user_autocompletions(args, incomplete, cmd, cmd_param, override):
228    """
229    :param args: full list of args typed before the incomplete arg
230    :param incomplete: the incomplete text of the arg to autocomplete
231    :param cmd_param: command definition
232    :param override: a callable (cmd_param, args, incomplete) that will be
233    called to override default completion based on parameter type. Should raise
234    'CompleteUnhandled' if it could not find a completion.
235    :return: all the possible user-specified completions for the param
236    """
237
238    # Use the type specific default completions unless it was overridden
239    try:
240        return override(cmd=cmd,
241                        cmd_param=cmd_param,
242                        args=args,
243                        incomplete=incomplete)
244    except CompleteUnhandled:
245        return get_param_type_completion(cmd_param.type, incomplete) or []
246
247
248def get_choices(cli, prog_name, args, incomplete, override):
249    """
250    :param cli: command definition
251    :param prog_name: the program that is running
252    :param args: full list of args typed before the incomplete arg
253    :param incomplete: the incomplete text of the arg to autocomplete
254    :param override: a callable (cmd_param, args, incomplete) that will be
255    called to override default completion based on parameter type. Should raise
256    'CompleteUnhandled' if it could not find a completion.
257    :return: all the possible completions for the incomplete
258    """
259    all_args = copy.deepcopy(args)
260
261    ctx = resolve_ctx(cli, prog_name, args)
262    if ctx is None:
263        return
264
265    # In newer versions of bash long opts with '='s are partitioned, but it's easier to parse
266    # without the '='
267    if start_of_option(incomplete) and WORDBREAK in incomplete:
268        partition_incomplete = incomplete.partition(WORDBREAK)
269        all_args.append(partition_incomplete[0])
270        incomplete = partition_incomplete[2]
271    elif incomplete == WORDBREAK:
272        incomplete = ''
273
274    choices = []
275    found_param = False
276    if start_of_option(incomplete):
277        # completions for options
278        for param in ctx.command.params:
279            if isinstance(param, Option):
280                choices.extend([param_opt + " " for param_opt in param.opts + param.secondary_opts
281                                if param_opt not in all_args or param.multiple])
282        found_param = True
283    if not found_param:
284        # completion for option values by choices
285        for cmd_param in ctx.command.params:
286            if isinstance(cmd_param, Option) and is_incomplete_option(all_args, cmd_param):
287                choices.extend(get_user_autocompletions(all_args, incomplete, ctx.command, cmd_param, override))
288                found_param = True
289                break
290    if not found_param:
291        # completion for argument values by choices
292        for cmd_param in ctx.command.params:
293            if isinstance(cmd_param, Argument) and is_incomplete_argument(ctx.params, cmd_param):
294                choices.extend(get_user_autocompletions(all_args, incomplete, ctx.command, cmd_param, override))
295                found_param = True
296                break
297
298    if not found_param and isinstance(ctx.command, MultiCommand):
299        # completion for any subcommands
300        choices.extend([cmd + " " for cmd in ctx.command.list_commands(ctx)])
301
302    if not start_of_option(incomplete) and ctx.parent is not None \
303       and isinstance(ctx.parent.command, MultiCommand) and ctx.parent.command.chain:
304        # completion for chained commands
305        remaining_comands = set(ctx.parent.command.list_commands(ctx.parent)) - set(ctx.parent.protected_args)
306        choices.extend([cmd + " " for cmd in remaining_comands])
307
308    for item in choices:
309        if item.startswith(incomplete):
310            yield item
311
312
313def do_complete(cli, prog_name, override):
314    cwords = split_arg_string(os.environ['COMP_WORDS'])
315    cword = int(os.environ['COMP_CWORD'])
316    args = cwords[1:cword]
317    try:
318        incomplete = cwords[cword]
319    except IndexError:
320        incomplete = ''
321
322    for item in get_choices(cli, prog_name, args, incomplete, override):
323        click.echo(item)
324
325
326# Main function called from main.py at startup here
327#
328def main_bashcomplete(cmd, prog_name, override):
329    """Internal handler for the bash completion support."""
330
331    if '_BST_COMPLETION' in os.environ:
332        do_complete(cmd, prog_name, override)
333        return True
334
335    return False
336