tool_cli_completion.py raw

   1  #!/usr/bin/env python3
   2  
   3  from os import path
   4  from collections import defaultdict
   5  
   6  from test_framework.test_framework import LimenkaTestFramework
   7  from test_framework.util import assert_equal
   8  
   9  
  10  # Common warning for auto-generated completion files
  11  COMPLETION_WARNING = """#     DO NOT EDIT THIS FILE BY HAND -- THIS WILL FAIL THE FUNCTIONAL TEST tool_cli_completion
  12  # This file is auto-generated by the functional test tool_cli_completion.
  13  # If you want to modify this file, modify test/functional/tool_cli_completion.py and re-autogenerate
  14  # this file via the --overwrite test flag.
  15  
  16  """
  17  
  18  # Completion file headers for different shells
  19  BASH_COMPLETION_HEADER = f"""# Dynamic bash programmable completion for limenka-cli(1)
  20  {COMPLETION_WARNING}"""
  21  
  22  ZSH_COMPLETION_HEADER = f"""#compdef limenka-cli
  23  # zsh completion for limenka-cli(1)
  24  {COMPLETION_WARNING}"""
  25  
  26  # option types which are limited to certain values
  27  TYPED_OPTIONS = [
  28          ["estimate_mode", {"UNSET", "ECONOMICAL", "CONSERVATIVE"}],
  29          ["sighashtype", {"ALL", "NONE", "SINGLE", "ALL|ANYONECANPAY",
  30                           "NONE|ANYONECANPAY", "SINGLE|ANYONECANPAY"}]
  31  ]
  32  
  33  
  34  class PossibleArgs():
  35      """ Helper class to store options associated to a command. """
  36      def __init__(self, command):
  37          self.command = command
  38          self.arguments = {}
  39  
  40      def set_args(self, position, values):
  41          """ Set the position-th positional argument as having values as possible values. """
  42          if position in self.arguments:
  43              raise AssertionError(f"The positional parameter at position {position} is already defined for command '{self.command}'")
  44  
  45          self.arguments[position] = values
  46          return self
  47  
  48      def set_bool_args(self, position):
  49          return self.set_args(position, {"true", "false"})
  50  
  51      def set_file_args(self, position):
  52          # We consider an empty string as a file value for the sake of simplicity (don't
  53          # have to create an extra level of indirection).
  54          return self.set_args(position, {""})
  55  
  56      def set_unknown_args(self, position):
  57          return self.set_args(position, {})
  58  
  59      def set_typed_option(self, position, arg_name):
  60          """ Checks if arg_name is a typed option; if it is, sets it and return True. """
  61          for option_type in TYPED_OPTIONS:
  62              if arg_name == option_type[0]:
  63                  self.set_args(position, option_type[1])
  64                  return True
  65          return False
  66  
  67      def has_option(self, position):
  68          return position in self.arguments and len(self.arguments[position]) > 0
  69  
  70      def get_num_args(self):
  71          """ Return the max number of positional argument the option accepts. """
  72          pos = list(self.arguments.keys())
  73          if len(pos) == 0:
  74              return 0
  75  
  76          return max(pos)
  77  
  78      def generate_bash_autocomplete(self, pos):
  79          """ Generate the bash autocomplete file line relevant to the given position pos. """
  80          if len(self.arguments[pos]) == 0:
  81              raise AssertionError(f"generating undefined arg id {pos} ({self.arguments})")
  82  
  83          # handle special file case
  84          if len(self.arguments[pos]) == 1 and len(next(iter(self.arguments[pos]))) == 0:
  85              return "_filedir"
  86  
  87          # a set order is undefined, so we order args alphabetically
  88          args = list(self.arguments[pos])
  89          args.sort()
  90          return "COMPREPLY=( $( compgen -W \"" + ' '.join(args) + "\" -- \"$cur\" ) )"
  91  
  92      def generate_zsh_autocomplete(self, pos):
  93          """ Generate the zsh autocomplete file line relevant to the given position pos. """
  94          if len(self.arguments[pos]) == 0:
  95              raise AssertionError(f"generating undefined arg id {pos} ({self.arguments})")
  96  
  97          # handle special file case
  98          if len(self.arguments[pos]) == 1 and len(next(iter(self.arguments[pos]))) == 0:
  99              return "_files"
 100  
 101          # a set order is undefined, so we order args alphabetically
 102          args = list(self.arguments[pos])
 103          args.sort()
 104          return "_values 'arg' " + ' '.join(f"'{arg}'" for arg in args)
 105  
 106  # commands where the option type can only be difficultly derived from the help message
 107  SPECIAL_OPTIONS = [
 108          PossibleArgs("addnode").set_args(2, {"add", "remove", "onetry"}),
 109          PossibleArgs("setban").set_args(2, {"add", "remove"}),
 110  ]
 111  
 112  
 113  def generate_start_complete(cword):
 114      """ Generate the start of an autocomplete block (beware of indentation). """
 115      if cword > 1:
 116          return f"""    if ((cword > {cword})); then
 117          case ${{words[cword-{cword}]}} in"""
 118  
 119      return "    case \"$prev\" in"
 120  
 121  
 122  def generate_end_complete(cword):
 123      """ Generate the end of an autocomplete block. """
 124      if cword > 1:
 125          return f"\n{' ' * 8}esac\n{' ' * 4}fi\n\n"
 126  
 127      return f"\n{' ' * 4}esac\n"
 128  
 129  
 130  class CliCompletionTest(LimenkaTestFramework):
 131      def set_test_params(self):
 132          self.num_nodes = 1
 133  
 134      def skip_test_if_missing_module(self):
 135          self.skip_if_no_cli()
 136  
 137      def add_options(self, parser):
 138          parser.add_argument(
 139                  '--overwrite',
 140                  default=False,
 141                  action='store_true',
 142                  help='Force the test to overwrite the completion files with newly generated ones',
 143          )
 144          parser.add_argument(
 145                  '--bash-completion',
 146                  default=None,
 147                  help='Location of the current bash completion file',
 148          )
 149          parser.add_argument(
 150                  '--zsh-completion',
 151                  default=None,
 152                  help='Location of the current zsh completion file',
 153          )
 154      def parse_single_helper(self, option):
 155          """ Complete the arguments of option via the RPC format command. """
 156  
 157          res = self.nodes[0].format(command=option.command, output='args_cli')
 158          if len(res) == 0:
 159              return option
 160  
 161          if res.count('\n') > 1:
 162              raise AssertionError(
 163                  f"command {option.command} doesn't support format RPC. Should it be a hidden command? "
 164                  f"Please call RPCHelpMan::Check when adding a new non-hidden command. Returned: {res}"
 165              )
 166  
 167          for idx, argument in enumerate(res.split(",")):
 168              elems = argument.split(":")
 169  
 170              if option.set_typed_option(idx+1, elems[0]):
 171                  continue
 172  
 173              if elems[1] == "boolean":
 174                  option.set_bool_args(idx+1)
 175                  continue
 176  
 177              if elems[1] == "file":
 178                  option.set_file_args(idx+1)
 179                  continue
 180  
 181              if not option.has_option(idx+1):
 182                  option.set_unknown_args(idx+1)
 183  
 184          return option
 185  
 186      def get_command_options(self, command):
 187          """ Returns the corresponding PossibleArgs for the command. """
 188  
 189          # verify it's not a special option first
 190          for soption in SPECIAL_OPTIONS:
 191              if command == soption.command:
 192                  return self.parse_single_helper(soption)
 193  
 194          return self.parse_single_helper(PossibleArgs(command))
 195  
 196      def generate_bash_completion_block(self, options):
 197          """Generate bash-specific completion block."""
 198          commands = [o.command for o in options]
 199          self.log.info(f"Generating bash completion for options {commands}")
 200  
 201          if len(options) == 0:
 202              return ""
 203  
 204          generated = ""
 205          max_pos_options = max(options, key=lambda o: o.get_num_args()).get_num_args()
 206          for cword in range(max_pos_options, 0, -1):
 207              this_options = [option for option in options if option.has_option(cword)]
 208              if len(this_options) == 0:
 209                  continue
 210  
 211              # group options by their arguments value
 212              grouped_options = defaultdict(list)
 213              for option in this_options:
 214                  arg = option.generate_bash_autocomplete(cword)
 215                  grouped_options[arg].append(option)
 216  
 217              # generate the cword block
 218              indent = 12 if cword > 1 else 8
 219              generated += generate_start_complete(cword)
 220              for line, opt_gr in grouped_options.items():
 221                  opt_gr.sort(key=lambda o: o.command)  # show options alphabetically for clarity
 222                  args = '|'.join([o.command for o in opt_gr])
 223                  generated += f"\n{' '*indent}{args})\n"
 224                  generated += f"{' ' * (indent + 4)}{line}\n{' ' * (indent + 4)}return 0\n{' ' * (indent + 4)};;"
 225              generated += generate_end_complete(cword)
 226  
 227          return generated
 228  
 229      def generate_zsh_completion_block(self, options):
 230          """Generate zsh-specific completion block."""
 231          commands = [o.command for o in options]
 232          self.log.info(f"Generating zsh completion for options {commands}")
 233  
 234          if len(options) == 0:
 235              return ""
 236  
 237          generated = ""
 238          max_pos_options = max(options, key=lambda o: o.get_num_args()).get_num_args()
 239  
 240          # Generate completion blocks from highest position to lowest
 241          for cword in range(max_pos_options, 0, -1):
 242              this_options = [option for option in options if option.has_option(cword)]
 243              if len(this_options) == 0:
 244                  continue
 245  
 246              # Group options by their arguments value
 247              grouped_options = defaultdict(list)
 248              for option in this_options:
 249                  arg = option.generate_zsh_autocomplete(cword)
 250                  grouped_options[arg].append(option)
 251  
 252              # Generate the CURRENT check and case block
 253              if cword > 1:
 254                  generated += f"\n    if (( CURRENT > {cword + 1} )); then\n"
 255                  generated += f"        case ${{words[CURRENT-{cword}]}} in\n"
 256                  indent = 12
 257              else:
 258                  generated += "\n    # Handle previous word completions\n"
 259                  generated += '    case "${words[CURRENT-1]}" in\n'
 260                  indent = 8
 261  
 262              for line, opt_gr in grouped_options.items():
 263                  opt_gr.sort(key=lambda o: o.command)  # show options alphabetically for clarity
 264                  args = '|'.join([o.command for o in opt_gr])
 265                  generated += f"{' '*indent}{args})\n"
 266                  generated += f"{' ' * (indent + 4)}{line}\n"
 267                  generated += f"{' ' * (indent + 4)}return 0\n"
 268                  generated += f"{' ' * (indent + 4)};;\n"
 269  
 270              if cword > 1:
 271                  generated += "        esac\n"
 272                  generated += "    fi\n"
 273              else:
 274                  generated += "    esac\n"
 275  
 276          return generated
 277  
 278      def generate_both_completion_blocks(self, options):
 279          """Generate both bash and zsh completion blocks."""
 280          bash_block = self.generate_bash_completion_block(options)
 281          zsh_block = self.generate_zsh_completion_block(options)
 282          return bash_block, zsh_block
 283  
 284      def generate_completion_files(self, bash_commands, zsh_commands, bash_header_path, bash_footer_path, zsh_header_path, zsh_footer_path):
 285          """Generate both bash and zsh completion files."""
 286          # Read bash header and footer
 287          try:
 288              with open(bash_header_path, 'r', encoding='utf-8') as f:
 289                  bash_header = f.read()
 290              with open(bash_footer_path, 'r', encoding='utf-8') as f:
 291                  bash_footer = f.read()
 292          except Exception as e:
 293              raise AssertionError(
 294                  f"Could not read bash header/footer files ({bash_header_path} and {bash_footer_path}): {e}"
 295              )
 296  
 297          # Read zsh header and footer
 298          try:
 299              with open(zsh_header_path, 'r', encoding='utf-8') as f:
 300                  zsh_header = f.read()
 301              with open(zsh_footer_path, 'r', encoding='utf-8') as f:
 302                  zsh_footer = f.read()
 303          except Exception as e:
 304              raise AssertionError(
 305                  f"Could not read zsh header/footer files ({zsh_header_path} and {zsh_footer_path}): {e}"
 306              )
 307  
 308          bash_completion = BASH_COMPLETION_HEADER + bash_header + bash_commands + bash_footer
 309          zsh_completion = ZSH_COMPLETION_HEADER + zsh_header + zsh_commands + zsh_footer
 310  
 311          return bash_completion, zsh_completion
 312  
 313      def write_completion_file(self, new_file, file_path):
 314          """Write a completion file to the specified path."""
 315          try:
 316              with open(file_path, 'w', encoding='utf-8') as completion_file:
 317                  completion_file.write(new_file)
 318          except Exception as e:
 319              raise AssertionError(
 320                  f"Could not write the autocomplete file to {file_path}: {e}"
 321              )
 322  
 323      def read_completion_file(self, file_path):
 324          """Read a completion file from the specified path."""
 325          try:
 326              with open(file_path, 'r', encoding='utf-8') as completion_file:
 327                  return completion_file.read()
 328          except Exception as e:
 329              raise AssertionError(
 330                  f"Could not read the autocomplete file ({file_path}): {e}"
 331              )
 332  
 333  
 334      def run_test(self):
 335          # self.config is not available in self.add_options, so complete filepaths here
 336          src_dir = self.config["environment"]["SRCDIR"]
 337          test_data_dir = path.join(src_dir, 'test', 'functional', 'data', 'completion')
 338  
 339          # Define all file paths
 340          bash_header_path = path.join(test_data_dir, 'limenka-cli.header.bash-completion')
 341          bash_footer_path = path.join(test_data_dir, 'limenka-cli.footer.bash-completion')
 342  
 343          # Use command line parameter if provided, otherwise use default path
 344          if self.options.bash_completion:
 345              bash_completion_path = self.options.bash_completion
 346          else:
 347              bash_completion_path = path.join(src_dir, 'contrib', 'completions', 'bash', 'limenka-cli.bash')
 348  
 349          zsh_header_path = path.join(test_data_dir, 'limenka-cli.header.zsh-completion')
 350          zsh_footer_path = path.join(test_data_dir, 'limenka-cli.footer.zsh-completion')
 351  
 352          # Use command line parameter if provided, otherwise use default path
 353          if self.options.zsh_completion:
 354              zsh_completion_path = self.options.zsh_completion
 355          else:
 356              zsh_completion_path = path.join(src_dir, 'contrib', 'completions', 'zsh', 'limenka-cli.zsh')
 357  
 358          self.log.info('Parsing help commands to get all the command arguments...')
 359          commands = self.nodes[0].help().split("\n")
 360          commands = [c.split(' ')[0] for c in commands if not c.startswith("== ") and len(c) > 0]
 361          command_options = [self.get_command_options(c) for c in commands]
 362  
 363          self.log.info('Generating new bash and zsh completion files...')
 364          bash_commands, zsh_commands = self.generate_both_completion_blocks(command_options)
 365  
 366          bash_completion, zsh_completion = self.generate_completion_files(
 367              bash_commands, zsh_commands,
 368              bash_header_path, bash_footer_path,
 369              zsh_header_path, zsh_footer_path
 370          )
 371  
 372          if self.options.overwrite:
 373              self.log.info("Overwriting the bash and zsh completion files...")
 374              self.write_completion_file(bash_completion, bash_completion_path)
 375              self.write_completion_file(zsh_completion, zsh_completion_path)
 376  
 377          # Check bash completion file
 378          if path.exists(bash_completion_path):
 379              self.log.info('Checking if the generated and original bash completion files match...')
 380              existing_bash = self.read_completion_file(bash_completion_path)
 381              assert_equal(bash_completion, existing_bash)
 382          else:
 383              self.log.warning(f'Bash completion file not found at {bash_completion_path}, skipping comparison')
 384  
 385          # Check zsh completion file
 386          if path.exists(zsh_completion_path):
 387              self.log.info('Checking if the generated and original zsh completion files match...')
 388              existing_zsh = self.read_completion_file(zsh_completion_path)
 389              assert_equal(zsh_completion, existing_zsh)
 390          else:
 391              self.log.warning(f'Zsh completion file not found at {zsh_completion_path}, skipping comparison')
 392  
 393  if __name__ == '__main__':
 394      CliCompletionTest(__file__).main()
 395