6969)
7070
7171import rich .box
72- from prompt_toolkit import print_formatted_text
72+ from prompt_toolkit import (
73+ filters ,
74+ print_formatted_text ,
75+ )
7376from prompt_toolkit .application import get_app
7477from prompt_toolkit .auto_suggest import AutoSuggestFromHistory
7578from prompt_toolkit .completion import Completer , DummyCompleter
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:
200205if TYPE_CHECKING : # pragma: no cover
201206 StaticArgParseBuilder = staticmethod [[], argparse .ArgumentParser ]
202207 ClassArgParseBuilder = classmethod ['Cmd' | CommandSet , [], argparse .ArgumentParser ]
208+ from prompt_toolkit .buffer import Buffer
203209else :
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.
0 commit comments