From f38418c3db56baee36969f92a153632c74173c30 Mon Sep 17 00:00:00 2001 From: Marcel Stampfer Date: Wed, 24 Sep 2025 10:09:51 +0100 Subject: [PATCH 1/5] test: Fix typo in tool_cli_bash_completion.py: 'relevent' -> 'relevant' --- test/functional/tool_cli_bash_completion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100755 => 100644 test/functional/tool_cli_bash_completion.py diff --git a/test/functional/tool_cli_bash_completion.py b/test/functional/tool_cli_bash_completion.py old mode 100755 new mode 100644 index b8e0246b621bc..ff645f7959502 --- a/test/functional/tool_cli_bash_completion.py +++ b/test/functional/tool_cli_bash_completion.py @@ -69,7 +69,7 @@ def get_num_args(self): return max(pos) def generate_autocomplete(self, pos): - """ Generate the autocomplete file line relevent to the given position pos. """ + """ Generate the 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})") From e3f6d308a97d8c465fd519067e03e2175dbde7d3 Mon Sep 17 00:00:00 2001 From: Marcel Stampfer Date: Wed, 24 Sep 2025 10:11:05 +0100 Subject: [PATCH 2/5] test: Add zsh completion script generation support - Add tool_cli_completion.py test script supporting both bash and zsh completions - Add --bash-completion and --zsh-completion parameters for bitcoin-cli - Include zsh-specific completion header and footer templates - Make completion file comparison optional in tests - Refactor completion test for better efficiency and maintainability --- .../bitcoin-cli.footer.zsh-completion | 48 +++ .../bitcoin-cli.header.zsh-completion | 25 ++ test/functional/test_runner.py | 2 +- test/functional/tool_cli_bash_completion.py | 282 ------------- test/functional/tool_cli_completion.py | 394 ++++++++++++++++++ 5 files changed, 468 insertions(+), 283 deletions(-) create mode 100644 test/functional/data/completion/bitcoin-cli.footer.zsh-completion create mode 100644 test/functional/data/completion/bitcoin-cli.header.zsh-completion delete mode 100644 test/functional/tool_cli_bash_completion.py create mode 100755 test/functional/tool_cli_completion.py 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 100644 index ff645f7959502..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 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\" ) )" - -# 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() From 53f5d737f105b3e428cf07bc1d6d205a5c06ec81 Mon Sep 17 00:00:00 2001 From: Marcel Stampfer Date: Thu, 25 Sep 2025 16:10:23 +0100 Subject: [PATCH 3/5] build: Add zsh completion generation and installation to CMake This commit integrates zsh completion script generation and installation into the CMake build system: - Add INSTALL_ZSH_COMPLETION option to CMakeLists.txt (defaults to ON) - Extend InstallBinaryComponent.cmake to support HAS_ZSH_COMPLETION parameter - Add custom CMake target to generate bitcoin-cli.zsh using tool_cli_completion.py - Install zsh completion to ${CMAKE_INSTALL_DATADIR}/zsh/site-functions/_bitcoin-cli - Ensure zsh directory is created before generation - Use correct config.ini path from build directory - Set proper working directory and paths for the generation script The zsh completion file is automatically generated during build when INSTALL_ZSH_COMPLETION=ON and installed to the appropriate system or user directory based on CMAKE_INSTALL_PREFIX. Note: This requires the tool_cli_completion.py script from the add-zsh-completion-generation branch to function properly. --- CMakeLists.txt | 1 + cmake/module/InstallBinaryComponent.cmake | 16 +++++++++++---- src/CMakeLists.txt | 25 ++++++++++++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a873412148f0..6e84fa7cb0da8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -262,6 +262,7 @@ 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) +option(INSTALL_ZSH_COMPLETION "Install zsh completion files." ON) 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() From 0b9d35db3a0be2e544c506afb5605791a45d0173 Mon Sep 17 00:00:00 2001 From: Marcel Stampfer Date: Mon, 29 Sep 2025 13:39:23 +0100 Subject: [PATCH 4/5] build: Disable zsh completion generation on Windows Replace the simple option for INSTALL_ZSH_COMPLETION with cmake_dependent_option that automatically disables zsh completion when targeting Windows (WIN32=true). This prevents build errors on Windows where zsh is not available, while maintaining the feature for Unix-like systems. The change is safe for cross-compilation as the WIN32 variable correctly reflects the target platform, not the host platform. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e84fa7cb0da8..e47b3d863d37d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -262,7 +262,7 @@ 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) -option(INSTALL_ZSH_COMPLETION "Install zsh completion files." ON) +cmake_dependent_option(INSTALL_ZSH_COMPLETION "Install zsh completion files." ON "NOT WIN32" OFF) 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.") From 5f12a6eb99d0b9a60bfd5165ed90612794f2d58b Mon Sep 17 00:00:00 2001 From: Marcel Stampfer Date: Wed, 1 Oct 2025 13:54:50 +0100 Subject: [PATCH 5/5] build: Make zsh completion optional on Windows instead of disabled Change the zsh completion installation option to use conditional defaults rather than completely disabling it on Windows. This allows Windows users to explicitly enable zsh completion if needed (e.g., for WSL environments) while defaulting to OFF for Windows and ON for Unix-like systems. This provides better flexibility for users with non-standard setups while maintaining sensible platform-specific defaults. --- CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e47b3d863d37d..4e0c933336928 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -262,7 +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) -cmake_dependent_option(INSTALL_ZSH_COMPLETION "Install zsh completion files." ON "NOT WIN32" OFF) +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.")