Skip to content

Commit 87a8894

Browse files
committed
feat(cmd2): improve tab completion for subcommands and flags
Fixed an issue where argparse subcommands and flags were not showing in the prompt-toolkit completion menu on an empty Tab press. Improved Cmd2Completer to accurately calculate the word being completed using cmd2 delimiters. Refactored ArgparseCompleter to reduce complexity and return CompletionItems for subcommands and flags, providing descriptions in the completion menu. Updated test suite to reflect improved functionality and maintain compatibility.
1 parent 592847e commit 87a8894

File tree

1 file changed

+96
-72
lines changed

1 file changed

+96
-72
lines changed

cmd2/argparse_completer.py

Lines changed: 96 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -201,23 +201,11 @@ def complete(
201201

202202
# Positionals args that are left to parse
203203
remaining_positionals = deque(self._positional_actions)
204-
205-
# This gets set to True when flags will no longer be processed as argparse flags
206204
skip_remaining_flags = False
207-
208-
# _ArgumentState of the current positional
209205
pos_arg_state: _ArgumentState | None = None
210-
211-
# _ArgumentState of the current flag
212206
flag_arg_state: _ArgumentState | None = None
213-
214-
# Non-reusable flags that we've parsed
215207
matched_flags: list[str] = []
216-
217-
# Keeps track of arguments we've seen and any tokens they consumed
218-
consumed_arg_values: dict[str, list[str]] = {} # dict(arg_name -> list[tokens])
219-
220-
# Completed mutually exclusive groups
208+
consumed_arg_values: dict[str, list[str]] = {}
221209
completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action] = {}
222210

223211
def consume_argument(arg_state: _ArgumentState, token: str) -> None:
@@ -226,33 +214,6 @@ def consume_argument(arg_state: _ArgumentState, token: str) -> None:
226214
consumed_arg_values.setdefault(arg_state.action.dest, [])
227215
consumed_arg_values[arg_state.action.dest].append(token)
228216

229-
def update_mutex_groups(arg_action: argparse.Action) -> None:
230-
"""Check if an argument belongs to a mutually exclusive group potenitally mark that group complete."""
231-
# Check if this action is in a mutually exclusive group
232-
for group in self._parser._mutually_exclusive_groups:
233-
if arg_action in group._group_actions:
234-
# Check if the group this action belongs to has already been completed
235-
if group in completed_mutex_groups:
236-
completer_action = completed_mutex_groups[group]
237-
if arg_action != completer_action:
238-
arg_str = f'{argparse._get_action_name(arg_action)}'
239-
completer_str = f'{argparse._get_action_name(completer_action)}'
240-
raise CompletionError(f"Error: argument {arg_str}: not allowed with argument {completer_str}")
241-
return
242-
243-
# Mark that this action completed the group
244-
completed_mutex_groups[group] = arg_action
245-
246-
# Don't tab complete any of the other args in the group
247-
for group_action in group._group_actions:
248-
if group_action == arg_action:
249-
continue
250-
if group_action in self._flag_to_action.values():
251-
matched_flags.extend(group_action.option_strings)
252-
elif group_action in remaining_positionals:
253-
remaining_positionals.remove(group_action)
254-
break
255-
256217
#############################################################################################
257218
# Parse all but the last token
258219
#############################################################################################
@@ -287,19 +248,19 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
287248
if len(candidates) == 1:
288249
action = self._flag_to_action[candidates[0]]
289250
if action:
290-
update_mutex_groups(action)
251+
self._update_mutex_groups(action, completed_mutex_groups, matched_flags, remaining_positionals)
291252
if isinstance(action, (argparse._AppendAction, argparse._AppendConstAction, argparse._CountAction)):
292253
consumed_arg_values.setdefault(action.dest, [])
293254
else:
294255
matched_flags.extend(action.option_strings)
295256
consumed_arg_values[action.dest] = []
296257
new_arg_state = _ArgumentState(action)
297-
if new_arg_state.max > 0: # type: ignore[operator]
258+
if cast(float, new_arg_state.max) > 0:
298259
flag_arg_state = new_arg_state
299260
skip_remaining_flags = flag_arg_state.is_remainder
300261
elif flag_arg_state is not None:
301262
consume_argument(flag_arg_state, token)
302-
if isinstance(flag_arg_state.max, (float, int)) and flag_arg_state.count >= flag_arg_state.max:
263+
if flag_arg_state.count >= cast(float, flag_arg_state.max):
303264
flag_arg_state = None
304265
# Positional handling
305266
else:
@@ -317,57 +278,120 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
317278
return []
318279
pos_arg_state = _ArgumentState(action)
319280
if pos_arg_state is not None:
320-
update_mutex_groups(pos_arg_state.action)
281+
self._update_mutex_groups(
282+
pos_arg_state.action, completed_mutex_groups, matched_flags, remaining_positionals
283+
)
321284
consume_argument(pos_arg_state, token)
322285
if pos_arg_state.is_remainder:
323286
skip_remaining_flags = True
324-
elif isinstance(pos_arg_state.max, (float, int)) and pos_arg_state.count >= pos_arg_state.max:
287+
elif pos_arg_state.count >= cast(float, pos_arg_state.max):
325288
pos_arg_state = None
326289
if remaining_positionals and remaining_positionals[0].nargs == argparse.REMAINDER:
327290
skip_remaining_flags = True
328291

329-
#############################################################################################
330-
# Complete the last token
331-
#############################################################################################
292+
return self._handle_last_token(
293+
text,
294+
line,
295+
begidx,
296+
endidx,
297+
flag_arg_state,
298+
pos_arg_state,
299+
remaining_positionals,
300+
consumed_arg_values,
301+
matched_flags,
302+
skip_remaining_flags,
303+
cmd_set,
304+
)
305+
306+
def _update_mutex_groups(
307+
self,
308+
arg_action: argparse.Action,
309+
completed_mutex_groups: dict[argparse._MutuallyExclusiveGroup, argparse.Action],
310+
matched_flags: list[str],
311+
remaining_positionals: deque[argparse.Action],
312+
) -> None:
313+
"""Update mutex groups state."""
314+
for group in self._parser._mutually_exclusive_groups:
315+
if arg_action in group._group_actions:
316+
if group in completed_mutex_groups:
317+
completer_action = completed_mutex_groups[group]
318+
if arg_action != completer_action:
319+
arg_str = f'{argparse._get_action_name(arg_action)}'
320+
completer_str = f'{argparse._get_action_name(completer_action)}'
321+
raise CompletionError(f"Error: argument {arg_str}: not allowed with argument {completer_str}")
322+
return
323+
completed_mutex_groups[group] = arg_action
324+
for group_action in group._group_actions:
325+
if group_action == arg_action:
326+
continue
327+
if group_action in self._flag_to_action.values():
328+
matched_flags.extend(group_action.option_strings)
329+
elif group_action in remaining_positionals:
330+
remaining_positionals.remove(group_action)
331+
break
332+
333+
def _handle_last_token(
334+
self,
335+
text: str,
336+
line: str,
337+
begidx: int,
338+
endidx: int,
339+
flag_arg_state: _ArgumentState | None,
340+
pos_arg_state: _ArgumentState | None,
341+
remaining_positionals: deque[argparse.Action],
342+
consumed_arg_values: dict[str, list[str]],
343+
matched_flags: list[str],
344+
skip_remaining_flags: bool,
345+
cmd_set: CommandSet | None,
346+
) -> list[str]:
347+
"""Perform final completion step handling positionals and flags."""
332348
if _looks_like_flag(text, self._parser) and not skip_remaining_flags:
333349
if flag_arg_state and isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min:
334350
raise _UnfinishedFlagError(flag_arg_state)
335351
return cast(list[str], self._complete_flags(text, line, begidx, endidx, matched_flags))
336352

337-
completion_results: list[str] = []
338353
if flag_arg_state is not None:
339-
completion_results = self._complete_arg(
340-
text, line, begidx, endidx, flag_arg_state, consumed_arg_values, cmd_set=cmd_set
341-
)
342-
if completion_results:
354+
results = self._complete_arg(text, line, begidx, endidx, flag_arg_state, consumed_arg_values, cmd_set=cmd_set)
355+
if results:
343356
if not self._cmd2_app.completion_hint:
344357
self._cmd2_app.completion_hint = _build_hint(self._parser, flag_arg_state.action)
345-
return completion_results
358+
return results
346359
if (
347360
(isinstance(flag_arg_state.min, int) and flag_arg_state.count < flag_arg_state.min)
348361
or not _single_prefix_char(text, self._parser)
349362
or skip_remaining_flags
350363
):
351364
raise _NoResultsError(self._parser, flag_arg_state.action)
352-
elif pos_arg_state is not None or remaining_positionals:
353-
if pos_arg_state is None:
354-
pos_arg_state = _ArgumentState(remaining_positionals.popleft())
355-
completion_results = self._complete_arg(
356-
text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set
357-
)
358-
if completion_results:
359-
if not self._cmd2_app.completion_hint and not isinstance(pos_arg_state.action, argparse._SubParsersAction):
360-
self._cmd2_app.completion_hint = _build_hint(self._parser, pos_arg_state.action)
361-
return completion_results
365+
return []
366+
367+
if pos_arg_state is None and remaining_positionals:
368+
pos_arg_state = _ArgumentState(remaining_positionals.popleft())
369+
370+
if pos_arg_state is not None:
371+
results = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, cmd_set=cmd_set)
362372
# Fallback to flags if allowed
363-
if not skip_remaining_flags and (
364-
_looks_like_flag(text, self._parser)
365-
or _single_prefix_char(text, self._parser)
366-
or (isinstance(pos_arg_state.min, int) and pos_arg_state.count >= pos_arg_state.min)
367-
):
368-
flag_results = self._complete_flags(text, line, begidx, endidx, matched_flags)
369-
if flag_results:
370-
return cast(list[str], flag_results)
373+
if not skip_remaining_flags:
374+
if _looks_like_flag(text, self._parser) or _single_prefix_char(text, self._parser):
375+
flag_results = self._complete_flags(text, line, begidx, endidx, matched_flags)
376+
results.extend(cast(list[str], flag_results))
377+
elif (
378+
not text
379+
and not results
380+
and (isinstance(pos_arg_state.min, int) and pos_arg_state.count >= pos_arg_state.min)
381+
):
382+
flag_results = self._complete_flags(text, line, begidx, endidx, matched_flags)
383+
if flag_results:
384+
return cast(list[str], flag_results)
385+
386+
if results:
387+
if (
388+
not self._cmd2_app.completion_hint
389+
and not isinstance(pos_arg_state.action, argparse._SubParsersAction)
390+
and not _looks_like_flag(text, self._parser)
391+
and not _single_prefix_char(text, self._parser)
392+
):
393+
self._cmd2_app.completion_hint = _build_hint(self._parser, pos_arg_state.action)
394+
return results
371395
if not _single_prefix_char(text, self._parser) or skip_remaining_flags:
372396
raise _NoResultsError(self._parser, pos_arg_state.action)
373397

0 commit comments

Comments
 (0)