Skip to content

Commit 070ad52

Browse files
committed
Switched to using prompt-toolkit's multiline filtering.
1 parent e40c771 commit 070ad52

File tree

3 files changed

+129
-112
lines changed

3 files changed

+129
-112
lines changed

cmd2/cmd2.py

Lines changed: 112 additions & 96 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
@@ -674,6 +707,8 @@ def _(event: Any) -> None: # pragma: no cover
674707
"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,55 +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
2897-
2898-
if not statement.command:
2899-
raise EmptyStatement
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)
29002921

2901-
# Add the complete command to prompt-toolkit's history.
2902-
cast(Cmd2History, self.main_session.history).add_command(statement.raw)
2922+
line += f'\n{nextline}'
29032923

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

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

29172939
# Continue until all macros are resolved
29182940
while True:
2919-
# Make sure all input has been read and convert it to a Statement
2941+
# Get a complete statement (handling multiline input)
29202942
statement = self._complete_statement(line)
29212943

29222944
# If this is the first loop iteration, save the original line
@@ -2926,28 +2948,30 @@ def _input_line_to_statement(self, line: str) -> Statement:
29262948
# Check if this command matches a macro and wasn't already processed to avoid an infinite loop
29272949
if statement.command in self.macros and statement.command not in used_macros:
29282950
used_macros.append(statement.command)
2929-
resolve_result = self._resolve_macro(statement)
2930-
if resolve_result is None:
2931-
raise EmptyStatement
2932-
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
29332956
else:
2957+
# No macro found or already processed. The statement is complete.
29342958
break
29352959

2936-
# If a macro was expanded, the 'statement' now contains the expanded text.
2937-
# We need to swap the 'raw' attribute back to the string the user typed
2938-
# so history shows the original line.
2960+
# Restore original 'raw' text if a macro was expanded
29392961
if orig_line != statement.raw:
29402962
statement_dict = statement.to_dict()
29412963
statement_dict["raw"] = orig_line
29422964
statement = Statement.from_dict(statement_dict)
29432965

29442966
return statement
29452967

2946-
def _resolve_macro(self, statement: Statement) -> str | None:
2968+
def _resolve_macro(self, statement: Statement) -> str:
29472969
"""Resolve a macro and return the resulting string.
29482970
29492971
:param statement: the parsed statement from the command line
2950-
: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)
29512975
"""
29522976
if statement.command not in self.macros:
29532977
raise KeyError(f"{statement.command} is not a macro")
@@ -2957,8 +2981,7 @@ def _resolve_macro(self, statement: Statement) -> str | None:
29572981
# Make sure enough arguments were passed in
29582982
if len(statement.arg_list) < macro.minimum_arg_count:
29592983
plural = '' if macro.minimum_arg_count == 1 else 's'
2960-
self.perror(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}")
2961-
return None
2984+
raise MacroError(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}")
29622985

29632986
# Resolve the arguments in reverse and read their values from statement.argv since those
29642987
# are unquoted. Macro args should have been quoted when the macro was created.
@@ -3402,25 +3425,18 @@ def _process_alerts(self) -> None:
34023425
# Clear the alerts
34033426
self._alert_queue.clear()
34043427

3405-
if alert_text:
3406-
if not self._at_continuation_prompt and latest_prompt is not None:
3407-
# Update prompt now so patch_stdout can redraw it immediately.
3408-
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
34093431

3432+
if alert_text:
34103433
# Print the alert messages above the prompt.
34113434
with patch_stdout():
34123435
print_formatted_text(pt_filter_style(alert_text))
34133436

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

34253441
def _read_command_line(self, prompt: str) -> str:
34263442
"""Read the next command line from the input stream.

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."""

cmd2/pt_utils.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -152,37 +152,30 @@ def get_completions(self, document: Document, _complete_event: object) -> Iterab
152152

153153

154154
class Cmd2History(History):
155-
"""An in-memory prompt-toolkit History implementation designed for cmd2.
155+
"""A non-persistent, in-memory history buffer for prompt-toolkit.
156156
157-
This class gives cmd2 total control over what appears in the up-arrow
158-
history, preventing multiline fragments from appearing in the navigation.
157+
This class serves as the backing store for UI history navigation (e.g., arrowing
158+
through previous commands). It explicitly avoids handling persistence,
159+
deferring all permanent storage logic to the cmd2 application.
159160
"""
160161

161162
def __init__(self, history_strings: Iterable[str] | None = None) -> None:
162163
"""Initialize the instance."""
163164
super().__init__()
164165

165166
if history_strings:
166-
# Use add_command() to filter consecutive duplicates
167-
# and save history strings from newest to oldest.
168167
for string in history_strings:
169-
self.add_command(string)
168+
self.append_string(string)
170169

171-
# Mark that self._loaded_strings is loaded.
170+
# Mark that self._loaded_strings is populated.
172171
self._loaded = True
173172

174-
def add_command(self, string: str) -> None:
175-
"""Manually add a finalized command string to the UI history stack.
176-
177-
Ensures consecutive duplicates are not stored.
178-
"""
179-
# self._loaded_strings is sorted newest to oldest, so we compare to the first element.
173+
def append_string(self, string: str) -> None:
174+
"""Override to filter our consecutive duplicates."""
175+
# History is sorted newest to oldest, so we compare to the first element.
180176
if string and (not self._loaded_strings or self._loaded_strings[0] != string):
181177
super().append_string(string)
182178

183-
def append_string(self, string: str) -> None:
184-
"""No-op: Blocks prompt-toolkit from storing multiline fragments."""
185-
186179
def store_string(self, string: str) -> None:
187180
"""No-op: Persistent history data is stored in cmd_app.history."""
188181

0 commit comments

Comments
 (0)