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