diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a873412148f0..4e0c933336928 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -262,6 +262,11 @@ option(BUILD_FUZZ_BINARY "Build fuzz binary." OFF) option(BUILD_FOR_FUZZING "Build for fuzzing. Enabling this will disable all other targets and override BUILD_FUZZ_BINARY." OFF) option(INSTALL_MAN "Install man pages." ON) +if(WIN32) + option(INSTALL_ZSH_COMPLETION "Install zsh completion files." OFF) +else() + option(INSTALL_ZSH_COMPLETION "Install zsh completion files." ON) +endif() set(APPEND_CPPFLAGS "" CACHE STRING "Preprocessor flags that are appended to the command line after all other flags added by the build system. This variable is intended for debugging and special builds.") set(APPEND_CFLAGS "" CACHE STRING "C compiler flags that are appended to the command line after all other flags added by the build system. This variable is intended for debugging and special builds.") diff --git a/cmake/module/InstallBinaryComponent.cmake b/cmake/module/InstallBinaryComponent.cmake index c7b2ed9ae6a48..e1d401e0bd5b1 100644 --- a/cmake/module/InstallBinaryComponent.cmake +++ b/cmake/module/InstallBinaryComponent.cmake @@ -7,10 +7,10 @@ include(GNUInstallDirs) function(install_binary_component component) cmake_parse_arguments(PARSE_ARGV 1 - IC # prefix - "HAS_MANPAGE" # options - "" # one_value_keywords - "" # multi_value_keywords + IC # prefix + "HAS_MANPAGE;HAS_ZSH_COMPLETION" # options + "" # one_value_keywords + "" # multi_value_keywords ) set(target_name ${component}) install(TARGETS ${target_name} @@ -23,4 +23,12 @@ function(install_binary_component component) COMPONENT ${component} ) endif() + if(INSTALL_ZSH_COMPLETION AND IC_HAS_ZSH_COMPLETION) + # Zsh completion files must be prefixed with underscore + install(FILES ${PROJECT_SOURCE_DIR}/contrib/completions/zsh/${target_name}.zsh + DESTINATION ${CMAKE_INSTALL_DATADIR}/zsh/site-functions + RENAME _${target_name} + COMPONENT ${component} + ) + endif() endfunction() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a456cb1ad4c47..bf673851ebfd0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -405,7 +405,30 @@ if(BUILD_CLI) libevent::core libevent::extra ) - install_binary_component(bitcoin-cli HAS_MANPAGE) + + # Generate zsh completion file + if(INSTALL_ZSH_COMPLETION) + set(ZSH_COMPLETION_FILE ${PROJECT_SOURCE_DIR}/contrib/completions/zsh/bitcoin-cli.zsh) + add_custom_command( + OUTPUT ${ZSH_COMPLETION_FILE} + COMMAND ${CMAKE_COMMAND} -E make_directory ${PROJECT_SOURCE_DIR}/contrib/completions/zsh + COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/test/functional/tool_cli_completion.py + --configfile=${PROJECT_BINARY_DIR}/test/config.ini + --zsh-completion=${ZSH_COMPLETION_FILE} + --overwrite + --cachedir=${PROJECT_BINARY_DIR}/test/cache + --tmpdir=${PROJECT_BINARY_DIR}/test/tmp_zsh_completion + DEPENDS bitcoin-cli bitcoind + WORKING_DIRECTORY ${PROJECT_BINARY_DIR}/src + COMMENT "Generating zsh completion for bitcoin-cli" + VERBATIM + ) + add_custom_target(generate_zsh_completion ALL + DEPENDS ${ZSH_COMPLETION_FILE} + ) + endif() + + install_binary_component(bitcoin-cli HAS_MANPAGE HAS_ZSH_COMPLETION) endif() diff --git a/test/functional/data/completion/bitcoin-cli.footer.zsh-completion b/test/functional/data/completion/bitcoin-cli.footer.zsh-completion new file mode 100644 index 0000000000000..fa0fa595d969a --- /dev/null +++ b/test/functional/data/completion/bitcoin-cli.footer.zsh-completion @@ -0,0 +1,48 @@ + # Handle current word completions + case "$words[CURRENT]" in + -conf=*) + local conf_path=${words[CURRENT]#-conf=} + _files -W ${conf_path:h} -g "*" + return 0 + ;; + -datadir=*) + local datadir_path=${words[CURRENT]#-datadir=} + _files -/ -W ${datadir_path:h} + return 0 + ;; + -*=*) + # prevent nonsense completions + return 0 + ;; + *) + local helpopts commands + local -a opts + + # only parse -help if sensible (empty or starts with -) + if [[ -z "$words[CURRENT]" || "$words[CURRENT]" == -* ]]; then + helpopts="$($bitcoin_cli -help 2>&1 | awk '$1 ~ /^-/ { sub(/=.*/, "="); print $1 }')" + opts+=(${(f)helpopts}) + fi + + # only parse help if sensible (empty or starts with letter) + if [[ -z "$words[CURRENT]" || "$words[CURRENT]" == [a-z]* ]]; then + commands="$(_bitcoin_rpc help 2>/dev/null | awk '$1 ~ /^[a-z]/ { print $1; }')" + opts+=(${(f)commands}) + fi + + _describe 'bitcoin-cli options and commands' opts + + return 0 + ;; + esac +} + +# Function is now defined and will be called by zsh completion system + +# Local variables: +# mode: shell-script +# sh-basic-offset: 4 +# sh-indent-comment: t +# indent-tabs-mode: nil +# End: +# ex: ts=4 sw=4 et filetype=sh diff --git a/test/functional/data/completion/bitcoin-cli.header.zsh-completion b/test/functional/data/completion/bitcoin-cli.header.zsh-completion new file mode 100644 index 0000000000000..5cd6c370226bc --- /dev/null +++ b/test/functional/data/completion/bitcoin-cli.header.zsh-completion @@ -0,0 +1,25 @@ +# Copyright (c) 2012-2024 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +# call bitcoin-cli for RPC +_bitcoin_rpc() { + # determine already specified args necessary for RPC + local rpcargs=() + local -a words_array + words_array=(${(z)BUFFER}) + + for i in $words_array; do + case "$i" in + -conf=*|-datadir=*|-regtest|-rpc*|-testnet|-testnet4) + rpcargs+=("$i") + ;; + esac + done + + $bitcoin_cli "${rpcargs[@]}" "$@" +} + +_bitcoin-cli() { + local context state line + local bitcoin_cli="$words[1]" diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 776f7daf1af3a..7fe9a11a7de74 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -196,7 +196,7 @@ 'mempool_resurrect.py', 'wallet_sweepprivkeys.py', 'wallet_txn_doublespend.py --mineblock', - 'tool_cli_bash_completion.py', + 'tool_cli_completion.py', 'tool_wallet.py --legacy-wallet', 'tool_wallet.py --legacy-wallet --bdbro', 'tool_wallet.py --legacy-wallet --bdbro --swap-bdb-endian', diff --git a/test/functional/tool_cli_bash_completion.py b/test/functional/tool_cli_bash_completion.py deleted file mode 100755 index b8e0246b621bc..0000000000000 --- a/test/functional/tool_cli_bash_completion.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python3 - -from os import path -from collections import defaultdict - -from test_framework.test_framework import BitcoinTestFramework -from test_framework.util import assert_equal - - -# bash cli completion file header -COMPLETION_HEADER = """# Dynamic bash programmable completion for bitcoin-cli(1) -# DO NOT EDIT THIS FILE BY HAND -- THIS WILL FAIL THE FUNCTIONAL TEST tool_cli_completion -# This file is auto-generated by the functional test tool_cli_completion. -# If you want to modify this file, modify test/functional/tool_cli_completion.py and re-autogenerate -# this file via the --overwrite test flag. - -""" - -# option types which are limited to certain values -TYPED_OPTIONS = [ - ["estimate_mode", {"UNSET", "ECONOMICAL", "CONSERVATIVE"}], - ["sighashtype", {"ALL", "NONE", "SINGLE", "ALL|ANYONECANPAY", - "NONE|ANYONECANPAY", "SINGLE|ANYONECANPAY"}] -] - - -class PossibleArgs(): - """ Helper class to store options associated to a command. """ - def __init__(self, command): - self.command = command - self.arguments = {} - - def set_args(self, position, values): - """ Set the position-th positional argument as having values as possible values. """ - if position in self.arguments: - raise AssertionError(f"The positional parameter at position {position} is already defined for command '{self.command}'") - - self.arguments[position] = values - return self - - def set_bool_args(self, position): - return self.set_args(position, {"true", "false"}) - - def set_file_args(self, position): - # We consider an empty string as a file value for the sake of simplicity (don't - # have to create an extra level of indirection). - return self.set_args(position, {""}) - - def set_unknown_args(self, position): - return self.set_args(position, {}) - - def set_typed_option(self, position, arg_name): - """ Checks if arg_name is a typed option; if it is, sets it and return True. """ - for option_type in TYPED_OPTIONS: - if arg_name == option_type[0]: - self.set_args(position, option_type[1]) - return True - return False - - def has_option(self, position): - return position in self.arguments and len(self.arguments[position]) > 0 - - def get_num_args(self): - """ Return the max number of positional argument the option accepts. """ - pos = list(self.arguments.keys()) - if len(pos) == 0: - return 0 - - return max(pos) - - def generate_autocomplete(self, pos): - """ Generate the autocomplete file line relevent to the given position pos. """ - if len(self.arguments[pos]) == 0: - raise AssertionError(f"generating undefined arg id {pos} ({self.arguments})") - - # handle special file case - if len(self.arguments[pos]) == 1 and len(next(iter(self.arguments[pos]))) == 0: - return "_filedir" - - # a set order is undefined, so we order args alphabetically - args = list(self.arguments[pos]) - args.sort() - - return "COMPREPLY=( $( compgen -W \"" + ' '.join(args) + "\" -- \"$cur\" ) )" - -# commands where the option type can only be difficultly derived from the help message -SPECIAL_OPTIONS = [ - PossibleArgs("addnode").set_args(2, {"add", "remove", "onetry"}), - PossibleArgs("setban").set_args(2, {"add", "remove"}), -] - - -def generate_start_complete(cword): - """ Generate the start of an autocomplete block (beware of indentation). """ - if cword > 1: - return f""" if ((cword > {cword})); then - case ${{words[cword-{cword}]}} in""" - - return " case \"$prev\" in" - - -def generate_end_complete(cword): - """ Generate the end of an autocomplete block. """ - if cword > 1: - return f"\n{' ' * 8}esac\n{' ' * 4}fi\n\n" - - return f"\n{' ' * 4}esac\n" - - -class CliCompletionTest(BitcoinTestFramework): - def set_test_params(self): - self.num_nodes = 1 - - def skip_test_if_missing_module(self): - self.skip_if_no_cli() - # self.skip_if_no_wallet() - self.skip_if_no_bitcoind_zmq() - - def add_options(self, parser): - parser.add_argument( - '--header', - help='Static header part of the bash completion file', - ) - - parser.add_argument( - '--footer', - help='Static footer part of the bash completion file', - ) - - parser.add_argument( - '--completion', - help='Location of the current bash completion file', - ) - - parser.add_argument( - '--overwrite', - default=False, - action='store_true', - help='Force the test to overwrite the file pointer to by the --completion' - 'to the newly generated completion file', - ) - def parse_single_helper(self, option): - """ Complete the arguments of option via the RPC format command. """ - - res = self.nodes[0].format(command=option.command, output='args_cli') - if len(res) == 0: - return option - - if res.count('\n') > 1: - raise AssertionError( - f"command {option.command} doesn't support format RPC. Should it be a hidden command? " - f"Please call RPCHelpMan::Check when adding a new non-hidden command. Returned: {res}" - ) - - for idx, argument in enumerate(res.split(",")): - elems = argument.split(":") - - if option.set_typed_option(idx+1, elems[0]): - continue - - if elems[1] == "boolean": - option.set_bool_args(idx+1) - continue - - if elems[1] == "file": - option.set_file_args(idx+1) - continue - - if not option.has_option(idx+1): - option.set_unknown_args(idx+1) - - return option - - def get_command_options(self, command): - """ Returns the corresponding PossibleArgs for the command. """ - - # verify it's not a special option first - for soption in SPECIAL_OPTIONS: - if command == soption.command: - return self.parse_single_helper(soption) - - return self.parse_single_helper(PossibleArgs(command)) - - def generate_completion_block(self, options): - commands = [o.command for o in options] - self.log.info(f"Generating part of the completion file for options {commands}") - - if len(options) == 0: - return "" - - generated = "" - max_pos_options = max(options, key=lambda o: o.get_num_args()).get_num_args() - for cword in range(max_pos_options, 0, -1): - this_options = [option for option in options if option.has_option(cword)] - if len(this_options) == 0: - continue - - # group options by their arguments value - grouped_options = defaultdict(list) - for option in this_options: - arg = option.generate_autocomplete(cword) - grouped_options[arg].append(option) - - # generate the cword block - indent = 12 if cword > 1 else 8 - generated += generate_start_complete(cword) - for line, opt_gr in grouped_options.items(): - opt_gr.sort(key=lambda o: o.command) # show options alphabetically for clarity - args = '|'.join([o.command for o in opt_gr]) - generated += f"\n{' '*indent}{args})\n" - generated += f"{' ' * (indent + 4)}{line}\n{' ' * (indent + 4)}return 0\n{' ' * (indent + 4)};;" - generated += generate_end_complete(cword) - - return generated - - def generate_completion_file(self, commands): - try: - with open(self.options.header, 'r', encoding='utf-8') as header_file: - header = header_file.read() - - with open(self.options.footer, 'r', encoding='utf-8') as footer_file: - footer = footer_file.read() - except Exception as e: - raise AssertionError( - f"Could not read header/footer ({self.options.header} and {self.options.footer}) files. " - f"Tell the test where to find them using the --header/--footer parameters ({e})." - ) - return COMPLETION_HEADER + header + commands + footer - - def write_completion_file(self, new_file): - try: - with open(self.options.completion, 'w', encoding='utf-8') as completion_file: - completion_file.write(new_file) - except Exception as e: - raise AssertionError( - f"Could not write the autocomplete file to {self.options.completion}. " - f"Tell the test where to find it using the --completion parameters ({e})." - ) - - def read_completion_file(self): - try: - with open(self.options.completion, 'r', encoding='utf-8') as completion_file: - return completion_file.read() - except Exception as e: - raise AssertionError( - f"Could not read the autocomplete file ({self.options.completion}) file. " - f"Tell the test where to find it using the --completion parameters ({e})." - ) - - - def run_test(self): - # self.config is not available in self.add_options, so complete filepaths here - src_dir = self.config["environment"]["SRCDIR"] - test_data_dir = path.join(src_dir, 'test', 'functional', 'data', 'completion') - if self.options.header is None or len(self.options.header) == 0: - self.options.header = path.join(test_data_dir, 'bitcoin-cli.header.bash-completion') - - if self.options.footer is None or len(self.options.footer) == 0: - self.options.footer = path.join(test_data_dir, 'bitcoin-cli.footer.bash-completion') - - if self.options.completion is None or len(self.options.completion) == 0: - self.options.completion = path.join(src_dir, 'contrib', 'completions', 'bash', 'bitcoin-cli.bash') - - self.log.info('Parsing help commands to get all the command arguments...') - commands = self.nodes[0].help().split("\n") - commands = [c.split(' ')[0] for c in commands if not c.startswith("== ") and len(c) > 0] - commands = [self.get_command_options(c) for c in commands] - - self.log.info('Generating new autocompletion file...') - commands = self.generate_completion_block(commands) - new_completion = self.generate_completion_file(commands) - - if self.options.overwrite: - self.log.info("Overwriting the completion file...") - self.write_completion_file(new_completion) - - self.log.info('Checking if the generated and the original completion files matches...') - completion = self.read_completion_file() - assert_equal(new_completion, completion) - -if __name__ == '__main__': - CliCompletionTest(__file__).main() diff --git a/test/functional/tool_cli_completion.py b/test/functional/tool_cli_completion.py new file mode 100755 index 0000000000000..65d66471519b8 --- /dev/null +++ b/test/functional/tool_cli_completion.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 + +from os import path +from collections import defaultdict + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal + + +# Common warning for auto-generated completion files +COMPLETION_WARNING = """# DO NOT EDIT THIS FILE BY HAND -- THIS WILL FAIL THE FUNCTIONAL TEST tool_cli_completion +# This file is auto-generated by the functional test tool_cli_completion. +# If you want to modify this file, modify test/functional/tool_cli_completion.py and re-autogenerate +# this file via the --overwrite test flag. + +""" + +# Completion file headers for different shells +BASH_COMPLETION_HEADER = f"""# Dynamic bash programmable completion for bitcoin-cli(1) +{COMPLETION_WARNING}""" + +ZSH_COMPLETION_HEADER = f"""#compdef bitcoin-cli +# zsh completion for bitcoin-cli(1) +{COMPLETION_WARNING}""" + +# option types which are limited to certain values +TYPED_OPTIONS = [ + ["estimate_mode", {"UNSET", "ECONOMICAL", "CONSERVATIVE"}], + ["sighashtype", {"ALL", "NONE", "SINGLE", "ALL|ANYONECANPAY", + "NONE|ANYONECANPAY", "SINGLE|ANYONECANPAY"}] +] + + +class PossibleArgs(): + """ Helper class to store options associated to a command. """ + def __init__(self, command): + self.command = command + self.arguments = {} + + def set_args(self, position, values): + """ Set the position-th positional argument as having values as possible values. """ + if position in self.arguments: + raise AssertionError(f"The positional parameter at position {position} is already defined for command '{self.command}'") + + self.arguments[position] = values + return self + + def set_bool_args(self, position): + return self.set_args(position, {"true", "false"}) + + def set_file_args(self, position): + # We consider an empty string as a file value for the sake of simplicity (don't + # have to create an extra level of indirection). + return self.set_args(position, {""}) + + def set_unknown_args(self, position): + return self.set_args(position, {}) + + def set_typed_option(self, position, arg_name): + """ Checks if arg_name is a typed option; if it is, sets it and return True. """ + for option_type in TYPED_OPTIONS: + if arg_name == option_type[0]: + self.set_args(position, option_type[1]) + return True + return False + + def has_option(self, position): + return position in self.arguments and len(self.arguments[position]) > 0 + + def get_num_args(self): + """ Return the max number of positional argument the option accepts. """ + pos = list(self.arguments.keys()) + if len(pos) == 0: + return 0 + + return max(pos) + + def generate_bash_autocomplete(self, pos): + """ Generate the bash autocomplete file line relevant to the given position pos. """ + if len(self.arguments[pos]) == 0: + raise AssertionError(f"generating undefined arg id {pos} ({self.arguments})") + + # handle special file case + if len(self.arguments[pos]) == 1 and len(next(iter(self.arguments[pos]))) == 0: + return "_filedir" + + # a set order is undefined, so we order args alphabetically + args = list(self.arguments[pos]) + args.sort() + return "COMPREPLY=( $( compgen -W \"" + ' '.join(args) + "\" -- \"$cur\" ) )" + + def generate_zsh_autocomplete(self, pos): + """ Generate the zsh autocomplete file line relevant to the given position pos. """ + if len(self.arguments[pos]) == 0: + raise AssertionError(f"generating undefined arg id {pos} ({self.arguments})") + + # handle special file case + if len(self.arguments[pos]) == 1 and len(next(iter(self.arguments[pos]))) == 0: + return "_files" + + # a set order is undefined, so we order args alphabetically + args = list(self.arguments[pos]) + args.sort() + return "_values 'arg' " + ' '.join(f"'{arg}'" for arg in args) + +# commands where the option type can only be difficultly derived from the help message +SPECIAL_OPTIONS = [ + PossibleArgs("addnode").set_args(2, {"add", "remove", "onetry"}), + PossibleArgs("setban").set_args(2, {"add", "remove"}), +] + + +def generate_start_complete(cword): + """ Generate the start of an autocomplete block (beware of indentation). """ + if cword > 1: + return f""" if ((cword > {cword})); then + case ${{words[cword-{cword}]}} in""" + + return " case \"$prev\" in" + + +def generate_end_complete(cword): + """ Generate the end of an autocomplete block. """ + if cword > 1: + return f"\n{' ' * 8}esac\n{' ' * 4}fi\n\n" + + return f"\n{' ' * 4}esac\n" + + +class CliCompletionTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + + def skip_test_if_missing_module(self): + self.skip_if_no_cli() + + def add_options(self, parser): + parser.add_argument( + '--overwrite', + default=False, + action='store_true', + help='Force the test to overwrite the completion files with newly generated ones', + ) + parser.add_argument( + '--bash-completion', + default=None, + help='Location of the current bash completion file', + ) + parser.add_argument( + '--zsh-completion', + default=None, + help='Location of the current zsh completion file', + ) + def parse_single_helper(self, option): + """ Complete the arguments of option via the RPC format command. """ + + res = self.nodes[0].format(command=option.command, output='args_cli') + if len(res) == 0: + return option + + if res.count('\n') > 1: + raise AssertionError( + f"command {option.command} doesn't support format RPC. Should it be a hidden command? " + f"Please call RPCHelpMan::Check when adding a new non-hidden command. Returned: {res}" + ) + + for idx, argument in enumerate(res.split(",")): + elems = argument.split(":") + + if option.set_typed_option(idx+1, elems[0]): + continue + + if elems[1] == "boolean": + option.set_bool_args(idx+1) + continue + + if elems[1] == "file": + option.set_file_args(idx+1) + continue + + if not option.has_option(idx+1): + option.set_unknown_args(idx+1) + + return option + + def get_command_options(self, command): + """ Returns the corresponding PossibleArgs for the command. """ + + # verify it's not a special option first + for soption in SPECIAL_OPTIONS: + if command == soption.command: + return self.parse_single_helper(soption) + + return self.parse_single_helper(PossibleArgs(command)) + + def generate_bash_completion_block(self, options): + """Generate bash-specific completion block.""" + commands = [o.command for o in options] + self.log.info(f"Generating bash completion for options {commands}") + + if len(options) == 0: + return "" + + generated = "" + max_pos_options = max(options, key=lambda o: o.get_num_args()).get_num_args() + for cword in range(max_pos_options, 0, -1): + this_options = [option for option in options if option.has_option(cword)] + if len(this_options) == 0: + continue + + # group options by their arguments value + grouped_options = defaultdict(list) + for option in this_options: + arg = option.generate_bash_autocomplete(cword) + grouped_options[arg].append(option) + + # generate the cword block + indent = 12 if cword > 1 else 8 + generated += generate_start_complete(cword) + for line, opt_gr in grouped_options.items(): + opt_gr.sort(key=lambda o: o.command) # show options alphabetically for clarity + args = '|'.join([o.command for o in opt_gr]) + generated += f"\n{' '*indent}{args})\n" + generated += f"{' ' * (indent + 4)}{line}\n{' ' * (indent + 4)}return 0\n{' ' * (indent + 4)};;" + generated += generate_end_complete(cword) + + return generated + + def generate_zsh_completion_block(self, options): + """Generate zsh-specific completion block.""" + commands = [o.command for o in options] + self.log.info(f"Generating zsh completion for options {commands}") + + if len(options) == 0: + return "" + + generated = "" + max_pos_options = max(options, key=lambda o: o.get_num_args()).get_num_args() + + # Generate completion blocks from highest position to lowest + for cword in range(max_pos_options, 0, -1): + this_options = [option for option in options if option.has_option(cword)] + if len(this_options) == 0: + continue + + # Group options by their arguments value + grouped_options = defaultdict(list) + for option in this_options: + arg = option.generate_zsh_autocomplete(cword) + grouped_options[arg].append(option) + + # Generate the CURRENT check and case block + if cword > 1: + generated += f"\n if (( CURRENT > {cword + 1} )); then\n" + generated += f" case ${{words[CURRENT-{cword}]}} in\n" + indent = 12 + else: + generated += "\n # Handle previous word completions\n" + generated += ' case "${words[CURRENT-1]}" in\n' + indent = 8 + + for line, opt_gr in grouped_options.items(): + opt_gr.sort(key=lambda o: o.command) # show options alphabetically for clarity + args = '|'.join([o.command for o in opt_gr]) + generated += f"{' '*indent}{args})\n" + generated += f"{' ' * (indent + 4)}{line}\n" + generated += f"{' ' * (indent + 4)}return 0\n" + generated += f"{' ' * (indent + 4)};;\n" + + if cword > 1: + generated += " esac\n" + generated += " fi\n" + else: + generated += " esac\n" + + return generated + + def generate_both_completion_blocks(self, options): + """Generate both bash and zsh completion blocks.""" + bash_block = self.generate_bash_completion_block(options) + zsh_block = self.generate_zsh_completion_block(options) + return bash_block, zsh_block + + def generate_completion_files(self, bash_commands, zsh_commands, bash_header_path, bash_footer_path, zsh_header_path, zsh_footer_path): + """Generate both bash and zsh completion files.""" + # Read bash header and footer + try: + with open(bash_header_path, 'r', encoding='utf-8') as f: + bash_header = f.read() + with open(bash_footer_path, 'r', encoding='utf-8') as f: + bash_footer = f.read() + except Exception as e: + raise AssertionError( + f"Could not read bash header/footer files ({bash_header_path} and {bash_footer_path}): {e}" + ) + + # Read zsh header and footer + try: + with open(zsh_header_path, 'r', encoding='utf-8') as f: + zsh_header = f.read() + with open(zsh_footer_path, 'r', encoding='utf-8') as f: + zsh_footer = f.read() + except Exception as e: + raise AssertionError( + f"Could not read zsh header/footer files ({zsh_header_path} and {zsh_footer_path}): {e}" + ) + + bash_completion = BASH_COMPLETION_HEADER + bash_header + bash_commands + bash_footer + zsh_completion = ZSH_COMPLETION_HEADER + zsh_header + zsh_commands + zsh_footer + + return bash_completion, zsh_completion + + def write_completion_file(self, new_file, file_path): + """Write a completion file to the specified path.""" + try: + with open(file_path, 'w', encoding='utf-8') as completion_file: + completion_file.write(new_file) + except Exception as e: + raise AssertionError( + f"Could not write the autocomplete file to {file_path}: {e}" + ) + + def read_completion_file(self, file_path): + """Read a completion file from the specified path.""" + try: + with open(file_path, 'r', encoding='utf-8') as completion_file: + return completion_file.read() + except Exception as e: + raise AssertionError( + f"Could not read the autocomplete file ({file_path}): {e}" + ) + + + def run_test(self): + # self.config is not available in self.add_options, so complete filepaths here + src_dir = self.config["environment"]["SRCDIR"] + test_data_dir = path.join(src_dir, 'test', 'functional', 'data', 'completion') + + # Define all file paths + bash_header_path = path.join(test_data_dir, 'bitcoin-cli.header.bash-completion') + bash_footer_path = path.join(test_data_dir, 'bitcoin-cli.footer.bash-completion') + + # Use command line parameter if provided, otherwise use default path + if self.options.bash_completion: + bash_completion_path = self.options.bash_completion + else: + bash_completion_path = path.join(src_dir, 'contrib', 'completions', 'bash', 'bitcoin-cli.bash') + + zsh_header_path = path.join(test_data_dir, 'bitcoin-cli.header.zsh-completion') + zsh_footer_path = path.join(test_data_dir, 'bitcoin-cli.footer.zsh-completion') + + # Use command line parameter if provided, otherwise use default path + if self.options.zsh_completion: + zsh_completion_path = self.options.zsh_completion + else: + zsh_completion_path = path.join(src_dir, 'contrib', 'completions', 'zsh', 'bitcoin-cli.zsh') + + self.log.info('Parsing help commands to get all the command arguments...') + commands = self.nodes[0].help().split("\n") + commands = [c.split(' ')[0] for c in commands if not c.startswith("== ") and len(c) > 0] + command_options = [self.get_command_options(c) for c in commands] + + self.log.info('Generating new bash and zsh completion files...') + bash_commands, zsh_commands = self.generate_both_completion_blocks(command_options) + + bash_completion, zsh_completion = self.generate_completion_files( + bash_commands, zsh_commands, + bash_header_path, bash_footer_path, + zsh_header_path, zsh_footer_path + ) + + if self.options.overwrite: + self.log.info("Overwriting the bash and zsh completion files...") + self.write_completion_file(bash_completion, bash_completion_path) + self.write_completion_file(zsh_completion, zsh_completion_path) + + # Check bash completion file + if path.exists(bash_completion_path): + self.log.info('Checking if the generated and original bash completion files match...') + existing_bash = self.read_completion_file(bash_completion_path) + assert_equal(bash_completion, existing_bash) + else: + self.log.warning(f'Bash completion file not found at {bash_completion_path}, skipping comparison') + + # Check zsh completion file + if path.exists(zsh_completion_path): + self.log.info('Checking if the generated and original zsh completion files match...') + existing_zsh = self.read_completion_file(zsh_completion_path) + assert_equal(zsh_completion, existing_zsh) + else: + self.log.warning(f'Zsh completion file not found at {zsh_completion_path}, skipping comparison') + +if __name__ == '__main__': + CliCompletionTest(__file__).main()