Skip to content

Commit df99fc8

Browse files
authored
Switch to using prompt-toolkit's multiline filtering. (#1589)
1 parent 5cb62a5 commit df99fc8

File tree

7 files changed

+269
-209
lines changed

7 files changed

+269
-209
lines changed

cmd2/cmd2.py

Lines changed: 114 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@
6969
)
7070

7171
import rich.box
72-
from prompt_toolkit import print_formatted_text
72+
from prompt_toolkit import (
73+
filters,
74+
print_formatted_text,
75+
)
7376
from prompt_toolkit.application import get_app
7477
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
7578
from prompt_toolkit.completion import Completer, DummyCompleter
@@ -136,6 +139,8 @@
136139
CompletionError,
137140
EmbeddedConsoleExit,
138141
EmptyStatement,
142+
IncompleteStatement,
143+
MacroError,
139144
PassThroughException,
140145
RedirectionError,
141146
SkipPostcommandHooks,
@@ -200,6 +205,7 @@ def __init__(self, msg: str = '') -> None:
200205
if TYPE_CHECKING: # pragma: no cover
201206
StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
202207
ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser]
208+
from prompt_toolkit.buffer import Buffer
203209
else:
204210
StaticArgParseBuilder = staticmethod
205211
ClassArgParseBuilder = classmethod
@@ -510,12 +516,6 @@ def __init__(
510516
# Used to keep track of whether we are redirecting or piping output
511517
self._redirecting = False
512518

513-
# Used to keep track of whether a continuation prompt is being displayed
514-
self._at_continuation_prompt = False
515-
516-
# The multiline command currently being typed which is used to complete multiline commands.
517-
self._multiline_in_progress = ''
518-
519519
# Characters used to draw a horizontal rule. Should not be blank.
520520
self.ruler = "─"
521521

@@ -643,6 +643,39 @@ def __init__(
643643
# the current command being executed
644644
self.current_command: Statement | None = None
645645

646+
def _should_continue_multiline(self) -> bool:
647+
"""Return whether prompt-toolkit should continue prompting the user for a multiline command."""
648+
buffer: Buffer = get_app().current_buffer
649+
line: str = buffer.text
650+
651+
used_macros = []
652+
653+
# Continue until all macros are resolved
654+
while True:
655+
try:
656+
statement = self._check_statement_complete(line)
657+
except IncompleteStatement:
658+
# The statement (or the resolved macro) is incomplete.
659+
# Keep prompting the user.
660+
return True
661+
662+
except (Cmd2ShlexError, EmptyStatement):
663+
# These are "finished" states (even if they are errors).
664+
# Submit so the main loop can handle the exception.
665+
return False
666+
667+
# Check if this command matches a macro and wasn't already processed to avoid an infinite loop
668+
if statement.command in self.macros and statement.command not in used_macros:
669+
used_macros.append(statement.command)
670+
try:
671+
line = self._resolve_macro(statement)
672+
except MacroError:
673+
# Resolve failed. Submit to let the main loop handle the error.
674+
return False
675+
else:
676+
# No macro found or already processed. The statement is complete.
677+
return False
678+
646679
def _create_main_session(self, auto_suggest: bool, completekey: str) -> PromptSession[str]:
647680
"""Create and return the main PromptSession for the application.
648681
@@ -671,9 +704,11 @@ def _(event: Any) -> None: # pragma: no cover
671704
"complete_in_thread": True,
672705
"complete_while_typing": False,
673706
"completer": Cmd2Completer(self),
674-
"history": Cmd2History(self),
707+
"history": Cmd2History(item.raw for item in self.history),
675708
"key_bindings": key_bindings,
676709
"lexer": Cmd2Lexer(self),
710+
"multiline": filters.Condition(self._should_continue_multiline),
711+
"prompt_continuation": self.continuation_prompt,
677712
"rprompt": self.get_rprompt,
678713
}
679714

@@ -2369,25 +2404,15 @@ def complete(
23692404
:return: a Completions object
23702405
"""
23712406
try:
2372-
# Check if we are completing a multiline command
2373-
if self._at_continuation_prompt:
2374-
# lstrip and prepend the previously typed portion of this multiline command
2375-
lstripped_previous = self._multiline_in_progress.lstrip()
2376-
line = lstripped_previous + line
2377-
2378-
# Increment the indexes to account for the prepended text
2379-
begidx = len(lstripped_previous) + begidx
2380-
endidx = len(lstripped_previous) + endidx
2381-
else:
2382-
# lstrip the original line
2383-
orig_line = line
2384-
line = orig_line.lstrip()
2385-
num_stripped = len(orig_line) - len(line)
2407+
# lstrip the original line
2408+
orig_line = line
2409+
line = orig_line.lstrip()
2410+
num_stripped = len(orig_line) - len(line)
23862411

2387-
# Calculate new indexes for the stripped line. If the cursor is at a position before the end of a
2388-
# line of spaces, then the following math could result in negative indexes. Enforce a max of 0.
2389-
begidx = max(begidx - num_stripped, 0)
2390-
endidx = max(endidx - num_stripped, 0)
2412+
# Calculate new indexes for the stripped line. If the cursor is at a position before the end of a
2413+
# line of spaces, then the following math could result in negative indexes. Enforce a max of 0.
2414+
begidx = max(begidx - num_stripped, 0)
2415+
endidx = max(endidx - num_stripped, 0)
23912416

23922417
# Shortcuts are not word break characters when completing. Therefore, shortcuts become part
23932418
# of the text variable if there isn't a word break, like a space, after it. We need to remove it
@@ -2843,6 +2868,36 @@ def runcmds_plus_hooks(
28432868

28442869
return False
28452870

2871+
def _check_statement_complete(self, line: str) -> Statement:
2872+
"""Check if the given line is a complete statement.
2873+
2874+
:param line: the current input string to check
2875+
:return: the completed Statement
2876+
:raises Cmd2ShlexError: if a shlex error occurs on a non-multiline command
2877+
:raises IncompleteStatement: if more input is needed for multiline
2878+
:raises EmptyStatement: if the command is blank
2879+
"""
2880+
try:
2881+
statement = self.statement_parser.parse(line)
2882+
2883+
# Check if we have a finished multiline command or a standard command
2884+
if (statement.multiline_command and statement.terminator) or not statement.multiline_command:
2885+
if not statement.command:
2886+
raise EmptyStatement
2887+
return statement
2888+
2889+
except Cmd2ShlexError:
2890+
# Check if the error is occurring within a multiline command
2891+
partial_statement = self.statement_parser.parse_command_only(line)
2892+
if not partial_statement.multiline_command:
2893+
# It's a standard command with a quoting error, raise it
2894+
raise
2895+
2896+
# If we reached here, the statement is incomplete:
2897+
# - Multiline command missing a terminator
2898+
# - Multiline command with an unclosed quotation mark
2899+
raise IncompleteStatement
2900+
28462901
def _complete_statement(self, line: str) -> Statement:
28472902
"""Keep accepting lines of input until the command is complete.
28482903
@@ -2853,52 +2908,22 @@ def _complete_statement(self, line: str) -> Statement:
28532908
"""
28542909
while True:
28552910
try:
2856-
statement = self.statement_parser.parse(line)
2857-
if statement.multiline_command and statement.terminator:
2858-
# we have a completed multiline command, we are done
2859-
break
2860-
if not statement.multiline_command:
2861-
# it's not a multiline command, but we parsed it ok
2862-
# so we are done
2863-
break
2864-
except Cmd2ShlexError:
2865-
# we have an unclosed quotation mark, let's parse only the command
2866-
# and see if it's a multiline
2867-
partial_statement = self.statement_parser.parse_command_only(line)
2868-
if not partial_statement.multiline_command:
2869-
# not a multiline command, so raise the exception
2870-
raise
2871-
2872-
# if we get here we must have:
2873-
# - a multiline command with no terminator
2874-
# - a multiline command with unclosed quotation marks
2875-
try:
2876-
self._at_continuation_prompt = True
2877-
2878-
# Save the command line up to this point for completion
2879-
self._multiline_in_progress = line + '\n'
2880-
2881-
# Get next line of this command
2911+
return self._check_statement_complete(line)
2912+
except IncompleteStatement: # noqa: PERF203
2913+
# If incomplete, we need to fetch the next line
28822914
try:
2883-
nextline = self._read_command_line(self.continuation_prompt)
2884-
except EOFError:
2885-
# Add a blank line, which serves as a command terminator.
2886-
nextline = '\n'
2887-
self.poutput(nextline)
2888-
2889-
line += f'\n{nextline}'
2890-
2891-
except KeyboardInterrupt:
2892-
self.poutput('^C')
2893-
statement = self.statement_parser.parse('')
2894-
break
2895-
finally:
2896-
self._at_continuation_prompt = False
2915+
try:
2916+
nextline = self._read_command_line(self.continuation_prompt)
2917+
except EOFError:
2918+
# Add a blank line, which serves as a command terminator.
2919+
nextline = '\n'
2920+
self.poutput(nextline)
28972921

2898-
if not statement.command:
2899-
raise EmptyStatement
2922+
line += f'\n{nextline}'
29002923

2901-
return statement
2924+
except KeyboardInterrupt:
2925+
self.poutput('^C')
2926+
raise EmptyStatement from None
29022927

29032928
def _input_line_to_statement(self, line: str) -> Statement:
29042929
"""Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved.
@@ -2913,7 +2938,7 @@ def _input_line_to_statement(self, line: str) -> Statement:
29132938

29142939
# Continue until all macros are resolved
29152940
while True:
2916-
# Make sure all input has been read and convert it to a Statement
2941+
# Get a complete statement (handling multiline input)
29172942
statement = self._complete_statement(line)
29182943

29192944
# If this is the first loop iteration, save the original line
@@ -2923,28 +2948,30 @@ def _input_line_to_statement(self, line: str) -> Statement:
29232948
# Check if this command matches a macro and wasn't already processed to avoid an infinite loop
29242949
if statement.command in self.macros and statement.command not in used_macros:
29252950
used_macros.append(statement.command)
2926-
resolve_result = self._resolve_macro(statement)
2927-
if resolve_result is None:
2928-
raise EmptyStatement
2929-
line = resolve_result
2951+
try:
2952+
line = self._resolve_macro(statement)
2953+
except MacroError as ex:
2954+
self.perror(ex)
2955+
raise EmptyStatement from None
29302956
else:
2957+
# No macro found or already processed. The statement is complete.
29312958
break
29322959

2933-
# If a macro was expanded, the 'statement' now contains the expanded text.
2934-
# We need to swap the 'raw' attribute back to the string the user typed
2935-
# so history shows the original line.
2960+
# Restore original 'raw' text if a macro was expanded
29362961
if orig_line != statement.raw:
29372962
statement_dict = statement.to_dict()
29382963
statement_dict["raw"] = orig_line
29392964
statement = Statement.from_dict(statement_dict)
29402965

29412966
return statement
29422967

2943-
def _resolve_macro(self, statement: Statement) -> str | None:
2968+
def _resolve_macro(self, statement: Statement) -> str:
29442969
"""Resolve a macro and return the resulting string.
29452970
29462971
:param statement: the parsed statement from the command line
2947-
:return: the resolved macro or None on error
2972+
:return: the resolved macro string
2973+
:raises KeyError: if its not a macro
2974+
:raises MacroError: if the macro cannot be resolved (e.g. not enough args)
29482975
"""
29492976
if statement.command not in self.macros:
29502977
raise KeyError(f"{statement.command} is not a macro")
@@ -2954,8 +2981,7 @@ def _resolve_macro(self, statement: Statement) -> str | None:
29542981
# Make sure enough arguments were passed in
29552982
if len(statement.arg_list) < macro.minimum_arg_count:
29562983
plural = '' if macro.minimum_arg_count == 1 else 's'
2957-
self.perror(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}")
2958-
return None
2984+
raise MacroError(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}")
29592985

29602986
# Resolve the arguments in reverse and read their values from statement.argv since those
29612987
# are unquoted. Macro args should have been quoted when the macro was created.
@@ -3399,25 +3425,18 @@ def _process_alerts(self) -> None:
33993425
# Clear the alerts
34003426
self._alert_queue.clear()
34013427

3402-
if alert_text:
3403-
if not self._at_continuation_prompt and latest_prompt is not None:
3404-
# Update prompt now so patch_stdout can redraw it immediately.
3405-
self.prompt = latest_prompt
3428+
if latest_prompt is not None:
3429+
# Update prompt so patch_stdout() or get_app().invalidate() can redraw it.
3430+
self.prompt = latest_prompt
34063431

3432+
if alert_text:
34073433
# Print the alert messages above the prompt.
34083434
with patch_stdout():
34093435
print_formatted_text(pt_filter_style(alert_text))
34103436

3411-
if self._at_continuation_prompt and latest_prompt is not None:
3412-
# Update state only. The onscreen prompt won't change until the next prompt starts.
3413-
self.prompt = latest_prompt
3414-
34153437
elif latest_prompt is not None:
3416-
self.prompt = latest_prompt
3417-
3418-
# Refresh UI immediately unless at a continuation prompt.
3419-
if not self._at_continuation_prompt:
3420-
get_app().invalidate()
3438+
# Refresh UI immediately to show the new prompt
3439+
get_app().invalidate()
34213440

34223441
def _read_command_line(self, prompt: str) -> str:
34233442
"""Read the next command line from the input stream.
@@ -4993,6 +5012,7 @@ def do_history(self, args: argparse.Namespace) -> bool | None:
49935012

49945013
# Clear command and prompt-toolkit history
49955014
self.history.clear()
5015+
cast(Cmd2History, self.main_session.history).clear()
49965016

49975017
if self.persistent_history_file:
49985018
try:

cmd2/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,13 @@ class EmptyStatement(Exception): # noqa: N818
7777
"""Custom exception class for handling behavior when the user just presses <Enter>."""
7878

7979

80+
class IncompleteStatement(Exception): # noqa: N818
81+
"""Raised when more input is required to complete a multiline statement."""
82+
83+
84+
class MacroError(Exception):
85+
"""Raised when a macro fails to resolve (e.g., insufficient arguments)."""
86+
87+
8088
class RedirectionError(Exception):
8189
"""Custom exception class for when redirecting or piping output fails."""

0 commit comments

Comments
 (0)