diff --git a/README.md b/README.md index eacf770968..70aa412d30 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ and on macOS we do not test LP solvers (yet). See [BUILD.md](BUILD.md). +## Tab completion + +See [TABCOMPLETION.md](TABCOMPLETION.md). ## Contributors diff --git a/TABCOMPLETION.md b/TABCOMPLETION.md new file mode 100644 index 0000000000..21ac99bac1 --- /dev/null +++ b/TABCOMPLETION.md @@ -0,0 +1,71 @@ +## Tab completion for Fast Downward + +We support tab completion for bash and zsh based on the python package [argcomplete](https://pypi.org/project/argcomplete/). For full support, use at least version 3.3 which can be installed via `pip`. + +```bash +pip install "argcomplete>=3.3" +``` + +After the installation, tab completion has to be enabled in one of two ways. + + +### Activating tab completion globally + +The global activation will enable tab completion for *all* Python files that support argcomplete (not only files related to Fast Downward). To activate the global tab completion execute the following command. Depending on your installation replace `activate-global-python-argcomplete` with `activate-global-python-argcomplete3`. + +```bash +activate-global-python-argcomplete +``` + + +### Activating tab completion locally + +In contrast to global activation, local activation only enables tab completion for files called `fast-downward.py`, `build.py`, or `translate.py`. However, activation is not limited to files that support argcomplete. This means that pressing tab on older version of Fast Downward files or unrelated files with the same name may have unintended side effects. For example, with older version of Fast Downward `build.py ` will start a build without printing the output. + +To activate the local tab completion, add the following commands to your `.bashrc` or `.zshrc`. Depending on your installation replace `register-python-argcomplete` with `register-python-argcomplete3`. + +```bash +eval "$(register-python-argcomplete fast-downward.py)" +eval "$(register-python-argcomplete build.py)" +eval "$(register-python-argcomplete translate.py)" +``` + +### Activating tab completion for the search binary + +If you are working with the search binary `downward` directly, adding the following commands to your `.bashrc` or `.zshrc` will enable tab completion. This is only necessary if you are not using the driver script `fast-downward.py`. + +```bash +function _downward_complete() { + local IFS=$'\013' + if [[ -n "${ZSH_VERSION-}" ]]; then + local DFS=":" + local completions + local COMP_CWORD=$((CURRENT - 1)) + completions=( $( "${words[1]}" --bash-complete \ + "$IFS" "$DFS" "$CURSOR" "$BUFFER" "$COMP_CWORD" ${words[@]})) + if [[ $? == 0 ]]; then + _describe "${words[1]}" completions -o nosort + fi + else + local DFS="" + COMPREPLY=( $( "$1" --bash-complete \ + "$IFS" "$DFS" "$COMP_POINT" "$COMP_LINE" "$COMP_CWORD" ${COMP_WORDS[@]})) + if [[ $? != 0 ]]; then + unset COMPREPLY + fi + fi +} + +if [[ -n "${ZSH_VERSION-}" ]]; then + compdef _downward_complete downward +else + complete -o nosort -F _downward_complete downward +fi +``` + +Restart your shell afterwards. + + +### Limitations + +The search configuration following the `--search` option is not yet covered by tab completion. For example, `fast-downward.py problem.pddl --search "ast"` will not suggest `astar`. \ No newline at end of file diff --git a/build.py b/build.py index c85f776cfe..c43a9891f1 100755 --- a/build.py +++ b/build.py @@ -1,12 +1,24 @@ #!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK +import argparse import errno import os +from pathlib import Path import subprocess import sys import build_configs +try: + import argcomplete + HAS_ARGCOMPLETE = True +except ImportError: + HAS_ARGCOMPLETE = False + +PROJECT_ROOT_PATH = Path(__file__).parent +SRC_PATH = PROJECT_ROOT_PATH / "src" + CONFIGS = {config: params for config, params in build_configs.__dict__.items() if not config.startswith("_")} DEFAULT_CONFIG_NAME = CONFIGS.pop("DEFAULT") @@ -24,7 +36,27 @@ # Number of available CPUs as a fall-back (may be None) NUM_CPUS = os.cpu_count() -def print_usage(): + +class RawHelpFormatter(argparse.HelpFormatter): + """Preserve newlines and spacing.""" + def _fill_text(self, text, width, indent): + return "".join([indent + line for line in text.splitlines(True)]) + + def _format_args(self, action, default_metavar): + """Show explicit help for remaining args instead of "...".""" + if action.nargs == argparse.REMAINDER: + return "[BUILD ...] [CMAKE_OPTION ...]" + else: + return argparse.HelpFormatter._format_args(self, action, default_metavar) + + +def parse_args(): + description = """Build one or more predefined build configurations of Fast Downward. Each build +uses CMake to compile the code. Build configurations differ in the parameters +they pass to CMake. By default, the build uses all available cores if this +number can be determined. Use the "-j" option for CMake to override this +default behaviour. +""" script_name = os.path.basename(__file__) configs = [] for name, args in sorted(CONFIGS.items()): @@ -34,56 +66,54 @@ def print_usage(): name += " (default with --debug)" configs.append(name + "\n " + " ".join(args)) configs_string = "\n ".join(configs) - cmake_name = os.path.basename(CMAKE) - generator_name = CMAKE_GENERATOR.lower() - default_config_name = DEFAULT_CONFIG_NAME - debug_config_name = DEBUG_CONFIG_NAME - print(f"""Usage: {script_name} [BUILD [BUILD ...]] [--all] [--debug] [MAKE_OPTIONS] - -Build one or more predefined build configurations of Fast Downward. Each build -uses {cmake_name} to compile the code using {generator_name} . Build configurations -differ in the parameters they pass to {cmake_name}. By default, the build uses all -available cores if this number can be determined. Use the "-j" option for -{cmake_name} to override this default behaviour. - -Build configurations + epilog = f"""build configurations: {configs_string} ---all Alias to build all build configurations. ---debug Alias to build the default debug build configuration. ---help Print this message and exit. - -Make options - All other parameters are forwarded to the build step. - -Example usage: - ./{script_name} # build {default_config_name} in #cores threads - ./{script_name} -j4 # build {default_config_name} in 4 threads - ./{script_name} debug # build debug - ./{script_name} --debug # build {debug_config_name} - ./{script_name} release debug # build release and debug configs - ./{script_name} --all VERBOSE=true # build all build configs with detailed logs -""") - - -def get_project_root_path(): - import __main__ - return os.path.dirname(__main__.__file__) - - -def get_builds_path(): - return os.path.join(get_project_root_path(), "builds") - - -def get_src_path(): - return os.path.join(get_project_root_path(), "src") +example usage: + {script_name} # build {DEFAULT_CONFIG_NAME} with #cores parallel processes + {script_name} -j4 # build {DEFAULT_CONFIG_NAME} with 4 parallel processes + {script_name} debug # build debug + {script_name} --debug # build {DEBUG_CONFIG_NAME} + {script_name} release debug # build release and debug configs + {script_name} --all VERBOSE=true # build all build configs with detailed logs +""" + + parser = argparse.ArgumentParser( + description=description, epilog=epilog, formatter_class=RawHelpFormatter) + parser.add_argument("--debug", action="store_true", + help="alias to build the default debug build configuration") + parser.add_argument("--all", action="store_true", + help="alias to build all build configurations") + remaining_args = parser.add_argument("arguments", nargs=argparse.REMAINDER, + help="build configurations (see below) or build options") + remaining_args.completer = complete_arguments + + if HAS_ARGCOMPLETE: + argcomplete.autocomplete(parser) + + # With calls like 'build.py -j4' the parser would try to interpret '-j4' as + # an option and fail to parse. We use parse_known_args to parse everything + # we recognize and add everything else to the list of arguments. + args, unparsed_args = parser.parse_known_args() + args.arguments = unparsed_args + args.arguments + + return args + + +def complete_arguments(prefix, parsed_args, **kwargs): + # This will modify parsed_args in place. This is not a problem because the + # process will stop after generating suggestions for the tab completion. + split_args(parsed_args) + unused_configs = set(CONFIGS) - set(parsed_args.config_names) + return sorted([c for c in unused_configs if c.startswith(prefix)]) def get_build_path(config_name): - return os.path.join(get_builds_path(), config_name) + return PROJECT_ROOT_PATH / "builds" / config_name + def try_run(cmd): - print(f'Executing command "{" ".join(cmd)}"') + print(f"""Executing command '{" ".join(cmd)}'""") try: subprocess.check_call(cmd) except OSError as exc: @@ -94,17 +124,18 @@ def try_run(cmd): else: raise + def build(config_name, configure_parameters, build_parameters): print(f"Building configuration {config_name}.") build_path = get_build_path(config_name) - generator_cmd = [CMAKE, "-S", get_src_path(), "-B", build_path] + generator_cmd = [CMAKE, "-S", str(SRC_PATH), "-B", str(build_path)] if CMAKE_GENERATOR: generator_cmd += ["-G", CMAKE_GENERATOR] generator_cmd += configure_parameters try_run(generator_cmd) - build_cmd = [CMAKE, "--build", build_path] + build_cmd = [CMAKE, "--build", str(build_path)] if NUM_CPUS: build_cmd += ["-j", f"{NUM_CPUS}"] if build_parameters: @@ -114,25 +145,27 @@ def build(config_name, configure_parameters, build_parameters): print(f"Built configuration {config_name} successfully.") +def split_args(args): + args.config_names = [] + args.build_parameters = [] + for arg in args.arguments: + if arg in CONFIGS: + args.config_names.append(arg) + elif arg != "--": + args.build_parameters.append(arg) + + if args.debug: + args.config_names.append(DEBUG_CONFIG_NAME) + if args.all: + args.config_names.extend(sorted(CONFIGS.keys())) + + def main(): - config_names = [] - build_parameters = [] - for arg in sys.argv[1:]: - if arg == "--help" or arg == "-h": - print_usage() - sys.exit(0) - elif arg == "--debug": - config_names.append(DEBUG_CONFIG_NAME) - elif arg == "--all": - config_names.extend(sorted(CONFIGS.keys())) - elif arg in CONFIGS: - config_names.append(arg) - else: - build_parameters.append(arg) - if not config_names: - config_names.append(DEFAULT_CONFIG_NAME) - for config_name in config_names: - build(config_name, CONFIGS[config_name], build_parameters) + args = parse_args() + split_args(args) + for config_name in args.config_names or [DEFAULT_CONFIG_NAME]: + build(config_name, CONFIGS[config_name], + args.build_parameters) if __name__ == "__main__": diff --git a/driver/arguments.py b/driver/arguments.py index 9800d1e795..f5216d608c 100644 --- a/driver/arguments.py +++ b/driver/arguments.py @@ -5,6 +5,7 @@ from . import aliases from . import returncodes +from . import tab_completion from . import util @@ -127,64 +128,6 @@ def _format_args(self, action, default_metavar): return argparse.HelpFormatter._format_args(self, action, default_metavar) -def _rindex(seq, element): - """Like list.index, but gives the index of the *last* occurrence.""" - seq = list(reversed(seq)) - reversed_index = seq.index(element) - return len(seq) - 1 - reversed_index - - -def _split_off_filenames(planner_args): - """Given the list of arguments to be passed on to the planner - components, split it into a prefix of filenames and a suffix of - options. Returns a pair (filenames, options). - - If a "--" separator is present, the last such separator serves as - the border between filenames and options. The separator itself is - not returned. (This implies that "--" can be a filename, but never - an option to a planner component.) - - If no such separator is present, the first argument that begins - with "-" and consists of at least two characters starts the list - of options, and all previous arguments are filenames.""" - - if "--" in planner_args: - separator_pos = _rindex(planner_args, "--") - num_filenames = separator_pos - del planner_args[separator_pos] - else: - num_filenames = 0 - for arg in planner_args: - # We treat "-" by itself as a filename because by common - # convention it denotes stdin or stdout, and we might want - # to support this later. - if arg.startswith("-") and arg != "-": - break - num_filenames += 1 - return planner_args[:num_filenames], planner_args[num_filenames:] - - -def _split_planner_args(parser, args): - """Partition args.planner_args, the list of arguments for the - planner components, into args.filenames, args.translate_options - and args.search_options. Modifies args directly and removes the original - args.planner_args list.""" - - args.filenames, options = _split_off_filenames(args.planner_args) - - args.translate_options = [] - args.search_options = [] - - curr_options = args.search_options - for option in options: - if option == "--translate-options": - curr_options = args.translate_options - elif option == "--search-options": - curr_options = args.search_options - else: - curr_options.append(option) - - def _check_mutex_args(parser, args, required=False): for pos, (name1, is_specified1) in enumerate(args): for name2, is_specified2 in args[pos + 1:]: @@ -392,9 +335,9 @@ def parse_args(): driver_other = parser.add_argument_group( title="other driver options") driver_other.add_argument( - "--alias", + "--alias", choices=aliases.ALIASES, help="run a config with an alias (e.g. seq-sat-lama-2011)") - driver_other.add_argument( + build_arg = driver_other.add_argument( "--build", help="BUILD can be a predefined build name like release " "(default) and debug, a custom build name, or the path to " @@ -403,6 +346,7 @@ def parse_args(): "this path does not exist, it tries the directory " "'/builds/BUILD/bin', where the build script creates " "them by default.") + build_arg.completer = tab_completion.complete_build_arg driver_other.add_argument( "--debug", action="store_true", help="alias for --build=debug --validate") @@ -441,9 +385,10 @@ def parse_args(): "--cleanup", action="store_true", help="clean up temporary files (translator output and plan files) and exit") - parser.add_argument( + planner_args = parser.add_argument( "planner_args", nargs=argparse.REMAINDER, help="file names and options passed on to planner components") + planner_args.completer = tab_completion.complete_planner_args # Using argparse.REMAINDER relies on the fact that the first # argument that doesn't belong to the driver doesn't look like an @@ -452,6 +397,7 @@ def parse_args(): # can be used as an explicit separator. For example, "./fast-downward.py -- # --help" passes "--help" to the search code. + tab_completion.enable(parser) args = parser.parse_args() if args.sas_file: @@ -463,13 +409,9 @@ def parse_args(): print_usage_and_exit_with_driver_input_error( parser, "The option --debug is an alias for --build=debug " "--validate. Do no specify both --debug and --build.") - if not args.build: - if args.debug: - args.build = "debug" - else: - args.build = "release" - _split_planner_args(parser, args) + util.set_default_build(args) + util.split_planner_args(args) _check_mutex_args(parser, [ ("--alias", args.alias is not None), diff --git a/driver/portfolio_runner.py b/driver/portfolio_runner.py index 746020596c..f70aa1087a 100644 --- a/driver/portfolio_runner.py +++ b/driver/portfolio_runner.py @@ -63,8 +63,8 @@ def adapt_args(args, search_cost_type, heuristic_cost_type, plan_manager): break -def run_search(executable, args, sas_file, plan_manager, time, memory): - complete_args = [executable] + args + [ +def run_search(command, args, sas_file, plan_manager, time, memory): + complete_args = command + args + [ "--internal-plan-file", plan_manager.get_plan_prefix()] print("args: %s" % complete_args) @@ -91,7 +91,7 @@ def compute_run_time(timeout, configs, pos): def run_sat_config(configs, pos, search_cost_type, heuristic_cost_type, - executable, sas_file, plan_manager, timeout, memory): + command, sas_file, plan_manager, timeout, memory): run_time = compute_run_time(timeout, configs, pos) if run_time <= 0: return None @@ -102,12 +102,12 @@ def run_sat_config(configs, pos, search_cost_type, heuristic_cost_type, args.extend([ "--internal-previous-portfolio-plans", str(plan_manager.get_plan_counter())]) - result = run_search(executable, args, sas_file, plan_manager, run_time, memory) + result = run_search(command, args, sas_file, plan_manager, run_time, memory) plan_manager.process_new_plans() return result -def run_sat(configs, executable, sas_file, plan_manager, final_config, +def run_sat(configs, command, sas_file, plan_manager, final_config, final_config_builder, timeout, memory): # If the configuration contains S_COST_TYPE or H_COST_TRANSFORM and the task # has non-unit costs, we start by treating all costs as one. When we find @@ -120,7 +120,7 @@ def run_sat(configs, executable, sas_file, plan_manager, final_config, for pos, (relative_time, args) in enumerate(configs): exitcode = run_sat_config( configs, pos, search_cost_type, heuristic_cost_type, - executable, sas_file, plan_manager, timeout, memory) + command, sas_file, plan_manager, timeout, memory) if exitcode is None: continue @@ -140,7 +140,7 @@ def run_sat(configs, executable, sas_file, plan_manager, final_config, heuristic_cost_type = "plusone" exitcode = run_sat_config( configs, pos, search_cost_type, heuristic_cost_type, - executable, sas_file, plan_manager, timeout, memory) + command, sas_file, plan_manager, timeout, memory) if exitcode is None: return @@ -162,18 +162,18 @@ def run_sat(configs, executable, sas_file, plan_manager, final_config, print("Abort portfolio and run final config.") exitcode = run_sat_config( [(1, final_config)], 0, search_cost_type, - heuristic_cost_type, executable, sas_file, plan_manager, + heuristic_cost_type, command, sas_file, plan_manager, timeout, memory) if exitcode is not None: yield exitcode -def run_opt(configs, executable, sas_file, plan_manager, timeout, memory): +def run_opt(configs, command, sas_file, plan_manager, timeout, memory): for pos, (relative_time, args) in enumerate(configs): run_time = compute_run_time(timeout, configs, pos) if run_time <= 0: return - exitcode = run_search(executable, args, sas_file, plan_manager, + exitcode = run_search(command, args, sas_file, plan_manager, run_time, memory) yield exitcode @@ -202,7 +202,7 @@ def get_portfolio_attributes(portfolio: Path): return attributes -def run(portfolio: Path, executable, sas_file, plan_manager, time, memory): +def run(portfolio: Path, command, sas_file, plan_manager, time, memory): """ Run the configs in the given portfolio file. @@ -231,9 +231,9 @@ def run(portfolio: Path, executable, sas_file, plan_manager, time, memory): if optimal: exitcodes = run_opt( - configs, executable, sas_file, plan_manager, timeout, memory) + configs, command, sas_file, plan_manager, timeout, memory) else: exitcodes = run_sat( - configs, executable, sas_file, plan_manager, final_config, + configs, command, sas_file, plan_manager, final_config, final_config_builder, timeout, memory) return returncodes.generate_portfolio_exitcode(list(exitcodes)) diff --git a/driver/run_components.py b/driver/run_components.py index bdb5df127d..ba6241c07f 100644 --- a/driver/run_components.py +++ b/driver/run_components.py @@ -1,3 +1,4 @@ +import errno import logging import os from pathlib import Path @@ -20,16 +21,30 @@ returncodes.exit_with_driver_unsupported_error("Unsupported OS: " + os.name) # TODO: We might want to turn translate into a module and call it with "python3 -m translate". -REL_TRANSLATE_PATH = Path("translate") / "translate.py" -REL_SEARCH_PATH = Path(f"downward{BINARY_EXT}") +_REL_TRANSLATE_PATH = Path("translate") / "translate.py" +_REL_SEARCH_PATH = Path(f"downward{BINARY_EXT}") # Older versions of VAL use lower case, newer versions upper case. We prefer the # older version because this is what our build instructions recommend. _VALIDATE_NAME = (shutil.which(f"validate{BINARY_EXT}") or shutil.which(f"Validate{BINARY_EXT}")) -VALIDATE = Path(_VALIDATE_NAME) if _VALIDATE_NAME else None +_VALIDATE_PATH = Path(_VALIDATE_NAME) if _VALIDATE_NAME else None -def get_executable(build: str, rel_path: Path): +class MissingBuildError(Exception): + pass + + +def get_search_command(build: str): + return [_get_executable(build, _REL_SEARCH_PATH)] + + +def get_translate_command(build: str): + assert sys.executable, "Path to interpreter could not be found" + abs_path = _get_executable(build, _REL_TRANSLATE_PATH) + return [sys.executable, abs_path] + + +def _get_executable(build: str, rel_path: Path): # First, consider 'build' to be a path directly to the binaries. # The path can be absolute or relative to the current working # directory. @@ -41,13 +56,13 @@ def get_executable(build: str, rel_path: Path): # '/builds//bin'. build_dir = util.BUILDS_DIR / build / "bin" if not build_dir.exists(): - returncodes.exit_with_driver_input_error( + raise MissingBuildError( f"Could not find build '{build}' at {build_dir}. " f"Please run './build.py {build}'.") abs_path = build_dir / rel_path if not abs_path.exists(): - returncodes.exit_with_driver_input_error( + raise MissingBuildError( f"Could not find '{rel_path}' in build '{build}'. " f"Please run './build.py {build}'.") @@ -60,13 +75,14 @@ def run_translate(args): args.translate_time_limit, args.overall_time_limit) memory_limit = limits.get_memory_limit( args.translate_memory_limit, args.overall_memory_limit) - translate = get_executable(args.build, REL_TRANSLATE_PATH) - assert sys.executable, "Path to interpreter could not be found" - cmd = [sys.executable] + [translate] + args.translate_inputs + args.translate_options + try: + translate_command = get_translate_command(args.build) + except MissingBuildError as e: + returncodes.exit_with_driver_input_error(e) stderr, returncode = call.get_error_output_and_returncode( "translator", - cmd, + translate_command + args.translate_inputs + args.translate_options, time_limit=time_limit, memory_limit=memory_limit) @@ -106,7 +122,10 @@ def run_search(args): args.search_time_limit, args.overall_time_limit) memory_limit = limits.get_memory_limit( args.search_memory_limit, args.overall_memory_limit) - executable = get_executable(args.build, REL_SEARCH_PATH) + try: + search_command = get_search_command(args.build) + except MissingBuildError as e: + returncodes.exit_with_driver_input_error(e) plan_manager = PlanManager( args.plan_file, @@ -118,7 +137,7 @@ def run_search(args): assert not args.search_options logging.info(f"search portfolio: {args.portfolio}") return portfolio_runner.run( - args.portfolio, executable, args.search_input, plan_manager, + args.portfolio, search_command, args.search_input, plan_manager, time_limit, memory_limit) else: if not args.search_options: @@ -129,7 +148,7 @@ def run_search(args): try: call.check_call( "search", - [executable] + args.search_options, + search_command + args.search_options, stdin=args.search_input, time_limit=time_limit, memory_limit=memory_limit) @@ -146,7 +165,7 @@ def run_search(args): def run_validate(args): - if not VALIDATE: + if not _VALIDATE_PATH: returncodes.exit_with_driver_input_error( "Error: Trying to run validate but it was not found on the PATH.") @@ -159,7 +178,7 @@ def run_validate(args): try: call.check_call( "validate", - [VALIDATE] + args.validate_inputs + plan_files, + [_VALIDATE_PATH] + args.validate_inputs + plan_files, time_limit=args.validate_time_limit, memory_limit=args.validate_memory_limit) except OSError as err: diff --git a/driver/tab_completion.py b/driver/tab_completion.py new file mode 100644 index 0000000000..4ffbb43648 --- /dev/null +++ b/driver/tab_completion.py @@ -0,0 +1,143 @@ +import os +import subprocess +import sys +import tempfile + +from . import util +from .run_components import get_search_command, get_translate_command, MissingBuildError + +try: + import argcomplete + HAS_ARGCOMPLETE = True +except ImportError: + HAS_ARGCOMPLETE = False + + +def complete_build_arg(prefix, parsed_args, **kwargs): + try: + return [p.name for p in util.BUILDS_DIR.iterdir() if p.is_dir()] + except OSError: + return [] + + +def complete_planner_args(prefix, parsed_args, **kwargs): + util.set_default_build(parsed_args) + + # Get some information from planner_args before it is deleted in split_planner_args(). + planner_args = parsed_args.planner_args + num_planner_args = len(planner_args) + double_dash_in_options = "--" in planner_args + + current_mode = util.split_planner_args(parsed_args) + num_filenames = len(parsed_args.filenames) + has_only_filename_options = (num_filenames == num_planner_args) + has_only_filename_or_double_dash_options = (num_filenames + int(double_dash_in_options) == num_planner_args) + can_use_double_dash = (1 <= num_planner_args <= 2) and has_only_filename_or_double_dash_options + + completions = {} + + if can_use_double_dash: + completions["--"] = "" + + if parsed_args.filenames or double_dash_in_options: + if current_mode == "search": + completions["--translate-options"] = "" + completions.update(_get_completions_from_downward( + parsed_args.build, parsed_args.search_options, prefix)) + else: + completions["--search-options"] = "" + completions.update(_get_completions_from_translator( + parsed_args.build, parsed_args.translate_options, prefix)) + + if has_only_filename_options and len(parsed_args.filenames) < 2: + file_completer = argcomplete.FilesCompleter() + completions.update({f: "" for f in file_completer(prefix, **kwargs)}) + + return completions + + +def _get_field_separators(env): + entry_separator = env.get("IFS", "\n") + help_separator = env.get("_ARGCOMPLETE_DFS") + if env.get("_ARGCOMPLETE_SHELL") == "zsh": + # Argcomplete always uses ":" on zsh, even if another value is set in + # _ARGCOMPLETE_DFS. + help_separator = ":" + return entry_separator, help_separator + + +def _split_argcomplete_output(content, entry_separator, help_separator): + suggestions = {} + for line in content.split(entry_separator): + if help_separator and help_separator in line: + suggestion, help = line.split(help_separator, 1) + suggestions[suggestion] = help + else: + suggestions[line] = "" + return suggestions + + +def _get_bash_completion_args(cmd, options, prefix): + """ + Return values for four environment variables, bash uses as part of tab + completion when cmd is called with parsed arguments args, and the unparsed + prefix of a word to be completed prefix. + COMP_POINT: the cursor position within COMP_LINE + COMP_LINE: the full command line as a string + COMP_CWORD: an index into COMP_WORDS to the word under the cursor. + COMP_WORDS: the command line as list of words + """ + comp_words = [str(x) for x in cmd] + options + [prefix] + comp_line = " ".join(comp_words) + comp_point = str(len(comp_line)) + comp_cword = str(len(comp_words) - 1) + return comp_point, comp_line, comp_cword, comp_words + + +def _call_argcomplete(cmd, comp_line, comp_point): + with tempfile.NamedTemporaryFile(mode="r") as f: + env = os.environ.copy() + env["COMP_LINE"] = comp_line + env["COMP_POINT"] = comp_point + env["_ARGCOMPLETE"] = "1" + env["_ARGCOMPLETE_STDOUT_FILENAME"] = f.name + subprocess.check_call(cmd, env=env) + entry_separator, help_separator = _get_field_separators(env) + return _split_argcomplete_output(f.read(), entry_separator, help_separator) + +def _get_completions_from_downward(build, options, prefix): + try: + search_command = get_search_command(build) + except MissingBuildError: + return {} + + entry_separator, help_separator = _get_field_separators(os.environ) + help_separator = help_separator or ":" + comp_point, comp_line, comp_cword, comp_words = _get_bash_completion_args( + search_command, options, prefix) + cmd = [str(x) for x in search_command] + ["--bash-complete", entry_separator, help_separator, + comp_point, comp_line, comp_cword] + comp_words + output = subprocess.check_output(cmd, text=True) + return _split_argcomplete_output(output, entry_separator, help_separator) + + +def _get_completions_from_translator(build, options, prefix): + try: + translate_command = get_translate_command(build) + except MissingBuildError: + return {} + + # We add domain and problem as dummy file names because otherwise, the + # translators tab completion will suggest filenames which we don't want at + # this point. Technically, file names should come after any options we + # complete but to consider them in the completion, they have to be to the + # left of the cursor position and to the left of any options that take + # parameters. + comp_point, comp_line, _, _ = _get_bash_completion_args( + translate_command, ["domain", "problem"] + options, prefix) + return _call_argcomplete(translate_command, comp_line, comp_point) + + +def enable(parser): + if HAS_ARGCOMPLETE: + argcomplete.autocomplete(parser) diff --git a/driver/tests.py b/driver/tests.py index 33f6c61d93..14a0a97150 100644 --- a/driver/tests.py +++ b/driver/tests.py @@ -17,7 +17,7 @@ from .call import check_call, _replace_paths_with_strings from . import limits from . import returncodes -from .run_components import get_executable, REL_SEARCH_PATH +from .run_components import get_search_command from .util import REPO_ROOT_DIR, find_domain_path @@ -89,7 +89,7 @@ def _convert_to_standalone_config(config): def _run_search(config): check_call( "search", - [get_executable("release", REL_SEARCH_PATH)] + list(config), + get_search_command("release") + list(config), stdin="output.sas") diff --git a/driver/util.py b/driver/util.py index 7016a2b806..a9527f228a 100644 --- a/driver/util.py +++ b/driver/util.py @@ -39,3 +39,75 @@ def find_domain_path(task_path: Path): returncodes.exit_with_driver_input_error( "Error: Could not find domain file using automatic naming rules.") + +def _rindex(seq, element): + """Like list.index, but gives the index of the *last* occurrence.""" + seq = list(reversed(seq)) + reversed_index = seq.index(element) + return len(seq) - 1 - reversed_index + + +def _split_off_filenames(planner_args): + """Given the list of arguments to be passed on to the planner + components, split it into a prefix of filenames and a suffix of + options. Returns a pair (filenames, options). + + If a "--" separator is present, the last such separator serves as + the border between filenames and options. The separator itself is + not returned. (This implies that "--" can be a filename, but never + an option to a planner component.) + + If no such separator is present, the first argument that begins + with "-" and consists of at least two characters starts the list + of options, and all previous arguments are filenames.""" + + if "--" in planner_args: + separator_pos = _rindex(planner_args, "--") + num_filenames = separator_pos + del planner_args[separator_pos] + else: + num_filenames = 0 + for arg in planner_args: + # We treat "-" by itself as a filename because by common + # convention it denotes stdin or stdout, and we might want + # to support this later. + if arg.startswith("-") and arg != "-": + break + num_filenames += 1 + return planner_args[:num_filenames], planner_args[num_filenames:] + + +def set_default_build(args): + """If no build is specified, set args.build to the default build. This is + typically 'release' but can be changed to 'debug' with the option + '--debug'. This function modifies args directly.""" + if not args.build: + if args.debug: + args.build = "debug" + else: + args.build = "release" + + +def split_planner_args(args): + """Partition args.planner_args, the list of arguments for the + planner components, into args.filenames, args.translate_options + and args.search_options. Modifies args directly. + Returns the name of the last active component for tab completion.""" + + args.filenames, options = _split_off_filenames(args.planner_args) + + args.translate_options = [] + args.search_options = [] + + curr_options = args.search_options + curr_option_name = "search" + for option in options: + if option == "--translate-options": + curr_options = args.translate_options + curr_option_name = "translate" + elif option == "--search-options": + curr_options = args.search_options + curr_option_name = "search" + else: + curr_options.append(option) + return curr_option_name diff --git a/fast-downward.py b/fast-downward.py index dc7c843a42..3fe78f90e9 100755 --- a/fast-downward.py +++ b/fast-downward.py @@ -1,4 +1,5 @@ #! /usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK if __name__ == "__main__": from driver.main import main diff --git a/src/search/command_line.cc b/src/search/command_line.cc index e8934d9240..1681975474 100644 --- a/src/search/command_line.cc +++ b/src/search/command_line.cc @@ -184,6 +184,146 @@ shared_ptr parse_cmd_line( return parse_cmd_line_aux(args); } +static vector> complete_args( + const vector &parsed_args, const string ¤t_word, + int cursor_pos) { + string prefix = current_word.substr(0, cursor_pos); + assert(!parsed_args.empty()); // args[0] is always the program name. + const string &last_arg = parsed_args.back(); + vector> suggestions; + if (find(parsed_args.begin(), parsed_args.end(), "--help") != parsed_args.end()) { + suggestions.emplace_back("--txt2tags", ""); + plugins::Registry registry = plugins::RawRegistry::instance()->construct_registry(); + for (const shared_ptr &feature : registry.get_features()) { + suggestions.emplace_back(feature->get_key(), feature->get_title()); + } + } else if (last_arg == "--internal-plan-file") { + /* Suggest filename starting with current_word. + Handeled by default bash completion. */ + exit(1); + } else if (last_arg == "--internal-previous-portfolio-plans") { + /* We want no suggestions and expect an integer + but we cannot avoid the default bash completion. */ + } else if (last_arg == "--search") { + /* Return suggestions for the search string based on current_word. + Not implemented at the moment. */ + exit(1); + } else { + // not completing an argument + suggestions.emplace_back("--help", ""); + suggestions.emplace_back("--search", ""); + suggestions.emplace_back("--internal-plan-file", ""); + suggestions.emplace_back("--internal-previous-portfolio-plans", ""); + suggestions.emplace_back("--if-unit-cost", ""); + suggestions.emplace_back("--if-non-unit-cost", ""); + suggestions.emplace_back("--always", ""); + // remove suggestions not starting with current_word + } + + if (!current_word.empty()) { + // Suggest only words that match with current_word + suggestions.erase( + remove_if(suggestions.begin(), suggestions.end(), + [&](const pair &value) { + return !value.first.starts_with(current_word); + }), suggestions.end()); + } + return suggestions; +} + +static vector get_word_starts(const string &command_line, const vector &words) { + vector word_starts; + word_starts.reserve(words.size()); + int pos = 0; + int end = static_cast(command_line.size()); + for (const string &word : words) { + int word_len = static_cast(word.size()); + if (command_line.substr(pos, word_len) != word) { + input_error("Expected '" + word + "' in command line at position " + + to_string(pos)); + } + word_starts.push_back(pos); + + // Skip word + pos += word_len; + + // Skip whitespace between words. + while (pos < end && isspace(command_line[pos])) { + ++pos; + } + } + return word_starts; +} + +static int get_position_in_current_word( + int cursor_word_index, const string &command_line, + int cursor_pos, const vector &words) { + vector word_starts = get_word_starts(command_line, words); + + assert(utils::in_bounds(cursor_word_index, words)); + const string ¤t_word = words[cursor_word_index]; + int len_current_word = static_cast(current_word.size()); + assert(utils::in_bounds(cursor_word_index, word_starts)); + int current_word_start = word_starts[cursor_word_index]; + int position_in_current_word = cursor_pos - current_word_start; + + if (position_in_current_word < 0 || position_in_current_word > len_current_word) { + input_error("Cursor position out-of-bounds: " + + to_string(position_in_current_word) + "."); + } + + return position_in_current_word; +} + +void handle_tab_completion(int argc, const char **argv) { + if (argc < 2 || string(argv[1]) != "--bash-complete") { + return; + } + if (argc < 7) { + input_error( + "The option --bash-complete is only meant to be called " + "internally to generate suggestions for tab completion.\n" + "Usage:\n ./downward --bash-complete $IFS $DFS\n" + "$COMP_POINT \"$COMP_LINE\" $COMP_CWORD ${COMP_WORDS[@]}\n" + "where the environment variables have their usual meaning for bash completion:\n" + "$IFS is a character used to separate different suggestions.\n" + "$DFS is a character used within a suggestion to separate the value from its description.\n" + "$COMP_POINT is the position of the cursor in the command line.\n" + "$COMP_LINE is the current command line.\n" + "$COMP_CWORD is an index into ${COMP_WORDS} of the word under the cursor.\n" + "$COMP_WORDS is the current command line split into words.\n" + ); + } + string entry_separator(argv[2]); + string help_separator(argv[3]); + int cursor_pos = parse_int_arg("COMP_POINT", argv[4]); + string command_line(argv[5]); + int cursor_word_index = parse_int_arg("COMP_CWORD", argv[6]); + vector words(&argv[7], &argv[argc]); + // Sentinel for cases where the cursor is after the last word. + words.push_back(""); + + if (!utils::in_bounds(cursor_word_index, words)) { + input_error("Cursor word index out-of-bounds: " + + to_string(cursor_word_index) + "."); + } + + vector preceding_words(words.begin(), words.begin() + cursor_word_index); + const string ¤t_word = words[cursor_word_index]; + int pos_in_word = get_position_in_current_word( + cursor_word_index, command_line, cursor_pos, words); + + for (const auto &[suggestion, description] : complete_args( + preceding_words, current_word, pos_in_word)) { + cout << suggestion; + if (!description.empty() && !help_separator.empty()) { + cout << help_separator << description; + } + cout << entry_separator; + } + // Do not use exit_with here because it would generate additional output. + exit(0); +} string usage(const string &progname) { return "usage: \n" + diff --git a/src/search/command_line.h b/src/search/command_line.h index aeec8bb058..56c60285c7 100644 --- a/src/search/command_line.h +++ b/src/search/command_line.h @@ -8,6 +8,7 @@ class SearchAlgorithm; extern std::shared_ptr parse_cmd_line( int argc, const char **argv, bool is_unit_cost); +extern void handle_tab_completion(int argc, const char **argv); extern std::string usage(const std::string &progname); diff --git a/src/search/planner.cc b/src/search/planner.cc index a524825698..8c2acf0e46 100644 --- a/src/search/planner.cc +++ b/src/search/planner.cc @@ -14,6 +14,12 @@ using utils::ExitCode; int main(int argc, const char **argv) { try { + /* + We have to handle tab completion before registering event handlers + because event handlers will print to stdout when the program exits + and everything on stdout counts as a suggestion for tab completion. + */ + handle_tab_completion(argc, argv); utils::register_event_handlers(); if (argc < 2) { @@ -22,7 +28,7 @@ int main(int argc, const char **argv) { } bool unit_cost = false; - if (static_cast(argv[1]) != "--help") { + if (string(argv[1]) != "--help") { utils::g_log << "reading input..." << endl; tasks::read_root_task(cin); utils::g_log << "done reading input!" << endl; diff --git a/src/translate/options.py b/src/translate/options.py index 091e8e1c83..3abebdb04a 100644 --- a/src/translate/options.py +++ b/src/translate/options.py @@ -2,6 +2,13 @@ import sys +try: + import argcomplete + HAS_ARGCOMPLETE = True +except ImportError: + HAS_ARGCOMPLETE = False + + def parse_args(): argparser = argparse.ArgumentParser() argparser.add_argument( @@ -57,6 +64,9 @@ def parse_args(): help="How to assign layers to derived variables. 'min' attempts to put as " "many variables into the same layer as possible, while 'max' puts each variable " "into its own layer unless it is part of a cycle.") + + if HAS_ARGCOMPLETE: + argcomplete.autocomplete(argparser) return argparser.parse_args() diff --git a/src/translate/translate.py b/src/translate/translate.py index d2df8cafb8..d7e946ca2f 100755 --- a/src/translate/translate.py +++ b/src/translate/translate.py @@ -1,4 +1,5 @@ #! /usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK import os