Repository: python-cmd2/cmd2
Pull request #729 (2019-07-18 00:30:14)

Enabled tab completion of multiline commands


Patch:
diff --git a/.travis.yml b/.travis.yml
index e05281a9..016756fe 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -44,7 +44,7 @@ before_script:
   # stop the build if there are Python syntax errors or undefined names
   # exit-zero treats all errors as warnings.  The GitHub editor is 127 chars wide
   if [[ $TOXENV == py37 ]]; then
-    flake8 . --count --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics ;
+    flake8 . --count --ignore=E252,W503 --max-complexity=26 --max-line-length=127 --show-source --statistics ;
   fi
 
 script:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abb440dd..e26c60d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
     * Greatly simplified using argparse-based tab completion. The new interface is a complete overhaul that breaks
     the previous way of specifying completion and choices functions. See header of [argparse_custom.py](https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_custom.py)
     for more information. 
+    * Enabled tab completion on multiline commands
 * **Renamed Commands Notice**
     * The following commands were renamed in the last release and have been removed in this release
         * `load` - replaced by `run_script`
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index c824bf97..a741882c 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -414,7 +414,7 @@ class AutoCompleter(object):
         """
         Supports cmd2's help command in the completion of sub-command names
         :param tokens: command line tokens
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index c8f2f9ff..1f07f2cb 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -428,6 +428,9 @@ class Cmd(cmd.Cmd):
         # Used to keep track of whether a continuation prompt is being displayed
         self._at_continuation_prompt = False
 
+        # The multiline command currently being typed which is used to tab complete multiline commands.
+        self._multiline_in_progress = ''
+
         # The error that prints when no help information can be found
         self.help_error = "No help on {}"
 
@@ -716,6 +719,7 @@ class Cmd(cmd.Cmd):
         self.allow_appended_space = True
         self.allow_closing_quote = True
         self.completion_header = ''
+        self.completion_matches = []
         self.display_matches = []
         self.matches_delimited = False
         self.matches_sorted = False
@@ -868,7 +872,7 @@ class Cmd(cmd.Cmd):
         In this case the delimiter would be :: and the user could easily narrow down what they are looking
         for if they were only shown suggestions in the category they are at in the string.
 
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
@@ -908,7 +912,7 @@ class Cmd(cmd.Cmd):
                             all_else: Union[None, Iterable, Callable] = None) -> List[str]:
         """Tab completes based on a particular flag preceding the token being completed.
 
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
@@ -950,7 +954,7 @@ class Cmd(cmd.Cmd):
                              all_else: Union[None, Iterable, Callable] = None) -> List[str]:
         """Tab completes based on a fixed position in the input string.
 
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
@@ -994,7 +998,7 @@ class Cmd(cmd.Cmd):
                       path_filter: Optional[Callable[[str], bool]] = None) -> List[str]:
         """Performs completion of local file system paths
 
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
@@ -1138,7 +1142,7 @@ class Cmd(cmd.Cmd):
                            complete_blank: bool = False) -> List[str]:
         """Performs completion of executables either in a user's path or a given path
 
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
@@ -1164,7 +1168,7 @@ class Cmd(cmd.Cmd):
         It determines if it should tab complete for redirection (|, >, >>) or use the
         completer function for the current command
 
-        :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
         :param line: the current input line with leading whitespace removed
         :param begidx: the beginning index of the prefix text
         :param endidx: the ending index of the prefix text
@@ -1344,237 +1348,241 @@ class Cmd(cmd.Cmd):
             # Display matches using actual display function. This also redraws the prompt and line.
             orig_pyreadline_display(matches_to_display)
 
-    def _complete_worker(self, text: str, state: int) -> Optional[str]:
-        """The actual worker function for tab completion which is called by complete() and returns
-        the next possible completion for 'text'.
-
-        If a command has not been entered, then complete against command list.
-        Otherwise try to call complete_<command> to get list of completions.
-
-        This completer function is called as complete(text, state), for state in 0, 1, 2, …, until it returns a
-        non-string value. It should return the next possible completion starting with text.
-
-        :param text: the current word that user is typing
-        :param state: non-negative integer
+    def _completion_for_command(self, text: str, line: str, begidx: int,
+                                endidx: int, shortcut_to_restore: str) -> None:
         """
-        import functools
-        if state == 0 and rl_type != RlType.NONE:
-            unclosed_quote = ''
-            self._reset_completion_defaults()
-
-            # lstrip the original line
-            orig_line = readline.get_line_buffer()
-            line = orig_line.lstrip()
-            stripped = len(orig_line) - len(line)
-
-            # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a
-            # line of spaces, then the following math could result in negative indexes. Enforce a max of 0.
-            begidx = max(readline.get_begidx() - stripped, 0)
-            endidx = max(readline.get_endidx() - stripped, 0)
-
-            # Shortcuts are not word break characters when tab completing. Therefore shortcuts become part
-            # of the text variable if there isn't a word break, like a space, after it. We need to remove it
-            # from text and update the indexes. This only applies if we are at the the beginning of the line.
-            shortcut_to_restore = ''
-            if begidx == 0:
-                for (shortcut, _) in self.statement_parser.shortcuts:
-                    if text.startswith(shortcut):
-                        # Save the shortcut to restore later
-                        shortcut_to_restore = shortcut
-
-                        # Adjust text and where it begins
-                        text = text[len(shortcut_to_restore):]
-                        begidx += len(shortcut_to_restore)
-                        break
+        Helper function for complete() that performs command-specific tab completion
 
-            # If begidx is greater than 0, then we are no longer completing the command
-            if begidx > 0:
+        :param text: the string prefix we are attempting to match (all matches must begin with it)
+        :param line: the current input line with leading whitespace removed
+        :param begidx: the beginning index of the prefix text
+        :param endidx: the ending index of the prefix text
+        :param shortcut_to_restore: if not blank, then this shortcut was removed from text and needs to be
+                                    prepended to all the matches
+        """
+        unclosed_quote = ''
 
-                # Parse the command line
-                statement = self.statement_parser.parse_command_only(line)
-                command = statement.command
-                expanded_line = statement.command_and_args
-
-                # We overwrote line with a properly formatted but fully stripped version
-                # Restore the end spaces since line is only supposed to be lstripped when
-                # passed to completer functions according to Python docs
-                rstripped_len = len(line) - len(line.rstrip())
-                expanded_line += ' ' * rstripped_len
-
-                # Fix the index values if expanded_line has a different size than line
-                if len(expanded_line) != len(line):
-                    diff = len(expanded_line) - len(line)
-                    begidx += diff
-                    endidx += diff
-
-                # Overwrite line to pass into completers
-                line = expanded_line
-
-                # Get all tokens through the one being completed
-                tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
-
-                # Check if we either had a parsing error or are trying to complete the command token
-                # The latter can happen if " or ' was entered as the command
-                if len(tokens) <= 1:
-                    self.completion_matches = []
-                    return None
-
-                # Text we need to remove from completions later
-                text_to_remove = ''
-
-                # Get the token being completed with any opening quote preserved
-                raw_completion_token = raw_tokens[-1]
-
-                # Check if the token being completed has an opening quote
-                if raw_completion_token and raw_completion_token[0] in constants.QUOTES:
-
-                    # Since the token is still being completed, we know the opening quote is unclosed
-                    unclosed_quote = raw_completion_token[0]
-
-                    # readline still performs word breaks after a quote. Therefore something like quoted search
-                    # text with a space would have resulted in begidx pointing to the middle of the token we
-                    # we want to complete. Figure out where that token actually begins and save the beginning
-                    # portion of it that was not part of the text readline gave us. We will remove it from the
-                    # completions later since readline expects them to start with the original text.
-                    actual_begidx = line[:endidx].rfind(tokens[-1])
-
-                    if actual_begidx != begidx:
-                        text_to_remove = line[actual_begidx:begidx]
-
-                        # Adjust text and where it begins so the completer routines
-                        # get unbroken search text to complete on.
-                        text = text_to_remove + text
-                        begidx = actual_begidx
-
-                # Check if a valid command was entered
-                if command in self.get_all_commands():
-                    # Get the completer function for this command
-                    compfunc = getattr(self, 'complete_' + command, None)
-
-                    if compfunc is None:
-                        # There's no completer function, next see if the command uses argparser
-                        func = self.cmd_func(command)
-                        if func and hasattr(func, 'argparser'):
-                            compfunc = functools.partial(self._autocomplete_default,
-                                                         argparser=getattr(func, 'argparser'))
-                        else:
-                            compfunc = self.completedefault
+        # Parse the command line
+        statement = self.statement_parser.parse_command_only(line)
+        command = statement.command
+        expanded_line = statement.command_and_args
 
-                # Check if a macro was entered
-                elif command in self.macros:
-                    compfunc = self.path_complete
+        # We overwrote line with a properly formatted but fully stripped version
+        # Restore the end spaces since line is only supposed to be lstripped when
+        # passed to completer functions according to Python docs
+        rstripped_len = len(line) - len(line.rstrip())
+        expanded_line += ' ' * rstripped_len
 
-                # A valid command was not entered
-                else:
-                    # Check if this command should be run as a shell command
-                    if self.default_to_shell and command in utils.get_exes_in_path(command):
-                        compfunc = self.path_complete
-                    else:
-                        compfunc = self.completedefault
+        # Fix the index values if expanded_line has a different size than line
+        if len(expanded_line) != len(line):
+            diff = len(expanded_line) - len(line)
+            begidx += diff
+            endidx += diff
 
-                # Attempt tab completion for redirection first, and if that isn't occurring,
-                # call the completer function for the current command
-                self.completion_matches = self._redirect_complete(text, line, begidx, endidx, compfunc)
+        # Overwrite line to pass into completers
+        line = expanded_line
 
-                if self.completion_matches:
+        # Get all tokens through the one being completed
+        tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
 
-                    # Eliminate duplicates
-                    self.completion_matches = utils.remove_duplicates(self.completion_matches)
-                    self.display_matches = utils.remove_duplicates(self.display_matches)
+        # Check if we either had a parsing error or are trying to complete the command token
+        # The latter can happen if " or ' was entered as the command
+        if len(tokens) <= 1:
+            return
 
-                    if not self.display_matches:
-                        # Since self.display_matches is empty, set it to self.completion_matches
-                        # before we alter them. That way the suggestions will reflect how we parsed
-                        # the token being completed and not how readline did.
-                        import copy
-                        self.display_matches = copy.copy(self.completion_matches)
+        # Text we need to remove from completions later
+        text_to_remove = ''
+
+        # Get the token being completed with any opening quote preserved
+        raw_completion_token = raw_tokens[-1]
+
+        # Check if the token being completed has an opening quote
+        if raw_completion_token and raw_completion_token[0] in constants.QUOTES:
+
+            # Since the token is still being completed, we know the opening quote is unclosed
+            unclosed_quote = raw_completion_token[0]
+
+            # readline still performs word breaks after a quote. Therefore something like quoted search
+            # text with a space would have resulted in begidx pointing to the middle of the token we
+            # we want to complete. Figure out where that token actually begins and save the beginning
+            # portion of it that was not part of the text readline gave us. We will remove it from the
+            # completions later since readline expects them to start with the original text.
+            actual_begidx = line[:endidx].rfind(tokens[-1])
+
+            if actual_begidx != begidx:
+                text_to_remove = line[actual_begidx:begidx]
+
+                # Adjust text and where it begins so the completer routines
+                # get unbroken search text to complete on.
+                text = text_to_remove + text
+                begidx = actual_begidx
+
+        # Check if a valid command was entered
+        if command in self.get_all_commands():
+            # Get the completer function for this command
+            compfunc = getattr(self, 'complete_' + command, None)
+
+            if compfunc is None:
+                # There's no completer function, next see if the command uses argparser
+                func = self.cmd_func(command)
+                if func and hasattr(func, 'argparser'):
+                    import functools
+                    compfunc = functools.partial(self._autocomplete_default,
+                                                 argparser=getattr(func, 'argparser'))
+                else:
+                    compfunc = self.completedefault
 
-                    # Check if we need to add an opening quote
-                    if not unclosed_quote:
+        # Check if a macro was entered
+        elif command in self.macros:
+            compfunc = self.path_complete
 
-                        add_quote = False
+        # A valid command was not entered
+        else:
+            # Check if this command should be run as a shell command
+            if self.default_to_shell and command in utils.get_exes_in_path(command):
+                compfunc = self.path_complete
+            else:
+                compfunc = self.completedefault
 
-                        # This is the tab completion text that will appear on the command line.
-                        common_prefix = os.path.commonprefix(self.completion_matches)
+        # Attempt tab completion for redirection first, and if that isn't occurring,
+        # call the completer function for the current command
+        self.completion_matches = self._redirect_complete(text, line, begidx, endidx, compfunc)
 
-                        if self.matches_delimited:
-                            # Check if any portion of the display matches appears in the tab completion
-                            display_prefix = os.path.commonprefix(self.display_matches)
+        if self.completion_matches:
 
-                            # For delimited matches, we check for a space in what appears before the display
-                            # matches (common_prefix) as well as in the display matches themselves.
-                            if ' ' in common_prefix or (display_prefix
-                                                        and any(' ' in match for match in self.display_matches)):
-                                add_quote = True
+            # Eliminate duplicates
+            self.completion_matches = utils.remove_duplicates(self.completion_matches)
+            self.display_matches = utils.remove_duplicates(self.display_matches)
 
-                        # If there is a tab completion and any match has a space, then add an opening quote
-                        elif common_prefix and any(' ' in match for match in self.completion_matches):
-                            add_quote = True
+            if not self.display_matches:
+                # Since self.display_matches is empty, set it to self.completion_matches
+                # before we alter them. That way the suggestions will reflect how we parsed
+                # the token being completed and not how readline did.
+                import copy
+                self.display_matches = copy.copy(self.completion_matches)
 
-                        if add_quote:
-                            # Figure out what kind of quote to add and save it as the unclosed_quote
-                            if any('"' in match for match in self.completion_matches):
-                                unclosed_quote = "'"
-                            else:
-                                unclosed_quote = '"'
+            # Check if we need to add an opening quote
+            if not unclosed_quote:
 
-                            self.completion_matches = [unclosed_quote + match for match in self.completion_matches]
+                add_quote = False
 
-                    # Check if we need to remove text from the beginning of tab completions
-                    elif text_to_remove:
-                        self.completion_matches = \
-                            [match.replace(text_to_remove, '', 1) for match in self.completion_matches]
+                # This is the tab completion text that will appear on the command line.
+                common_prefix = os.path.commonprefix(self.completion_matches)
 
-                    # Check if we need to restore a shortcut in the tab completions
-                    # so it doesn't get erased from the command line
-                    if shortcut_to_restore:
-                        self.completion_matches = \
-                            [shortcut_to_restore + match for match in self.completion_matches]
+                if self.matches_delimited:
+                    # Check if any portion of the display matches appears in the tab completion
+                    display_prefix = os.path.commonprefix(self.display_matches)
 
-            else:
-                # Complete token against anything a user can run
-                self.completion_matches = utils.basic_complete(text, line, begidx, endidx,
-                                                               self._get_commands_aliases_and_macros_for_completion())
+                    # For delimited matches, we check for a space in what appears before the display
+                    # matches (common_prefix) as well as in the display matches themselves.
+                    if ' ' in common_prefix or (display_prefix
+                                                and any(' ' in match for match in self.display_matches)):
+                        add_quote = True
 
-            # Handle single result
-            if len(self.completion_matches) == 1:
-                str_to_append = ''
+                # If there is a tab completion and any match has a space, then add an opening quote
+                elif common_prefix and any(' ' in match for match in self.completion_matches):
+                    add_quote = True
 
-                # Add a closing quote if needed and allowed
-                if self.allow_closing_quote and unclosed_quote:
-                    str_to_append += unclosed_quote
+                if add_quote:
+                    # Figure out what kind of quote to add and save it as the unclosed_quote
+                    if any('"' in match for match in self.completion_matches):
+                        unclosed_quote = "'"
+                    else:
+                        unclosed_quote = '"'
 
-                # If we are at the end of the line, then add a space if allowed
-                if self.allow_appended_space and endidx == len(line):
-                    str_to_append += ' '
+                    self.completion_matches = [unclosed_quote + match for match in self.completion_matches]
 
-                self.completion_matches[0] += str_to_append
+            # Check if we need to remove text from the beginning of tab completions
+            elif text_to_remove:
+                self.completion_matches = [match.replace(text_to_remove, '', 1) for match in self.completion_matches]
 
-            # Sort matches if they haven't already been sorted
-            if not self.matches_sorted:
-                self.completion_matches.sort(key=self.default_sort_key)
-                self.display_matches.sort(key=self.default_sort_key)
-                self.matches_sorted = True
+            # Check if we need to restore a shortcut in the tab completions
+            # so it doesn't get erased from the command line
+            if shortcut_to_restore:
+                self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches]
 
-        try:
-            return self.completion_matches[state]
-        except IndexError:
-            return None
+            # If we have one result, then add a closing quote if needed and allowed
+            if len(self.completion_matches) == 1 and self.allow_closing_quote and unclosed_quote:
+                self.completion_matches[0] += unclosed_quote
 
     def complete(self, text: str, state: int) -> Optional[str]:
         """Override of cmd2's complete method which returns the next possible completion for 'text'
 
-        This method gets called directly by readline. Since readline suppresses any exception raised
-        in completer functions, they can be difficult to debug. Therefore this function wraps the
-        actual tab completion logic and prints to stderr any exception that occurs before returning
-        control to readline.
+        This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …,
+        until it returns a non-string value. It should return the next possible completion starting with text.
+
+        Since readline suppresses any exception raised in completer functions, they can be difficult to debug.
+        Therefore this function wraps the actual tab completion logic and prints to stderr any exception that
+        occurs before returning control to readline.
 
         :param text: the current word that user is typing
         :param state: non-negative integer
+        :return: the next possible completion for text or None
         """
         # noinspection PyBroadException
         try:
-            return self._complete_worker(text, state)
+            if state == 0 and rl_type != RlType.NONE:
+                self._reset_completion_defaults()
+
+                # Check if we are completing a multiline command
+                if self._at_continuation_prompt:
+                    # lstrip and prepend the previously typed portion of this multiline command
+                    lstripped_previous = self._multiline_in_progress.lstrip()
+                    line = lstripped_previous + readline.get_line_buffer()
+
+                    # Increment the indexes to account for the prepended text
+                    begidx = len(lstripped_previous) + readline.get_begidx()
+                    endidx = len(lstripped_previous) + readline.get_endidx()
+                else:
+                    # lstrip the original line
+                    orig_line = readline.get_line_buffer()
+                    line = orig_line.lstrip()
+                    num_stripped = len(orig_line) - len(line)
+
+                    # Calculate new indexes for the stripped line. If the cursor is at a position before the end of a
+                    # line of spaces, then the following math could result in negative indexes. Enforce a max of 0.
+                    begidx = max(readline.get_begidx() - num_stripped, 0)
+                    endidx = max(readline.get_endidx() - num_stripped, 0)
+
+                # Shortcuts are not word break characters when tab completing. Therefore shortcuts become part
+                # of the text variable if there isn't a word break, like a space, after it. We need to remove it
+                # from text and update the indexes. This only applies if we are at the the beginning of the line.
+                shortcut_to_restore = ''
+                if begidx == 0:
+                    for (shortcut, _) in self.statement_parser.shortcuts:
+                        if text.startswith(shortcut):
+                            # Save the shortcut to restore later
+                            shortcut_to_restore = shortcut
+
+                            # Adjust text and where it begins
+                            text = text[len(shortcut_to_restore):]
+                            begidx += len(shortcut_to_restore)
+                            break
+
+                # If begidx is greater than 0, then we are no longer completing the first token (command name)
+                if begidx > 0:
+                    self._completion_for_command(text, line, begidx, endidx, shortcut_to_restore)
+
+                # Otherwise complete token against anything a user can run
+                else:
+                    match_against = self._get_commands_aliases_and_macros_for_completion()
+                    self.completion_matches = utils.basic_complete(text, line, begidx, endidx, match_against)
+
+                # If we have one result and we are at the end of the line, then add a space if allowed
+                if len(self.completion_matches) == 1 and endidx == len(line) and self.allow_appended_space:
+                    self.completion_matches[0] += ' '
+
+                # Sort matches if they haven't already been sorted
+                if not self.matches_sorted:
+                    self.completion_matches.sort(key=self.default_sort_key)
+                    self.display_matches.sort(key=self.default_sort_key)
+                    self.matches_sorted = True
+
+            try:
+                return self.completion_matches[state]
+            except IndexError:
+                return None
+
         except Exception as e:
             # Insert a newline so the exception doesn't print in the middle of the command line being tab completed
             self.perror('\n', end='')
@@ -1854,15 +1862,19 @@ class Cmd(cmd.Cmd):
             #   - a multiline command with unclosed quotation marks
             try:
                 self._at_continuation_prompt = True
-                newline = self._pseudo_raw_input(self.continuation_prompt)
-                if newline == 'eof':
+
+                # Save the command line up to this point for tab completion
+                self._multiline_in_progress = line + '\n'
+
+                nextline = self._pseudo_raw_input(self.continuation_prompt)
+                if nextline == 'eof':
                     # they entered either a blank line, or we hit an EOF
                     # for some other reason. Turn the literal 'eof'
                     # into a blank line, which serves as a command
                     # terminator
-                    newline = '\n'
-                    self.poutput(newline)
-                line = '{}\n{}'.format(statement.raw, newline)
+                    nextline = '\n'
+                    self.poutput(nextline)
+                line = '{}{}'.format(self._multiline_in_progress, nextline)
             except KeyboardInterrupt as ex:
                 if self.quit_on_sigint:
                     raise ex
diff --git a/cmd2/utils.py b/cmd2/utils.py
index 7f357a6c..57d0deee 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -624,7 +624,7 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against
     Basic tab completion function that matches against a list of strings without considering line contents
     or cursor position. The args required by this function are defined in the header of Pythons's cmd.py.
 
-    :param text: the string prefix we are attempting to match (all returned matches must begin with it)
+    :param text: the string prefix we are attempting to match (all matches must begin with it)
     :param line: the current input line with leading whitespace removed
     :param begidx: the beginning index of the prefix text
     :param endidx: the ending index of the prefix text
diff --git a/tasks.py b/tasks.py
index b679e594..f7df006b 100644
--- a/tasks.py
+++ b/tasks.py
@@ -239,5 +239,5 @@ namespace.add_task(pypi_test)
 @invoke.task
 def flake8(context):
     "Run flake8 linter and tool for style guide enforcement"
-    context.run("flake8 --ignore=E252,W503 --max-complexity=31 --max-line-length=127 --show-source --statistics --exclude=.git,__pycache__,.tox,.eggs,*.egg,.venv,.idea,.pytest_cache,.vscode,build,dist,htmlcov")
+    context.run("flake8 --ignore=E252,W503 --max-complexity=26 --max-line-length=127 --show-source --statistics --exclude=.git,__pycache__,.tox,.eggs,*.egg,.venv,.idea,.pytest_cache,.vscode,build,dist,htmlcov")
 namespace.add_task(flake8)


Test patch:
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 3cee1955..233783c6 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -61,7 +61,7 @@ class CompletionsExample(cmd2.Cmd):
     Example cmd2 application used to exercise tab-completion tests
     """
     def __init__(self):
-        cmd2.Cmd.__init__(self)
+        cmd2.Cmd.__init__(self, multiline_commands=['test_multiline'])
 
     def do_test_basic(self, args):
         pass
@@ -88,6 +88,12 @@ class CompletionsExample(cmd2.Cmd):
     def complete_test_raise_exception(self, text, line, begidx, endidx):
         raise IndexError("You are out of bounds!!")
 
+    def do_test_multiline(self, args):
+        pass
+
+    def complete_test_multiline(self, text, line, begidx, endidx):
+        return utils.basic_complete(text, line, begidx, endidx, sport_item_strs)
+
     def do_test_no_completer(self, args):
         """Completing this should result in completedefault() being called"""
         pass
@@ -128,7 +134,7 @@ def test_complete_empty_arg(cmd2_app):
     endidx = len(line)
     begidx = endidx - len(text)
 
-    expected = sorted(cmd2_app.get_visible_commands())
+    expected = sorted(cmd2_app.get_visible_commands(), key=cmd2_app.default_sort_key)
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
 
     assert first_match is not None and cmd2_app.completion_matches == expected
@@ -733,7 +739,8 @@ def test_add_opening_quote_basic_no_text(cmd2_app):
 
     # The whole list will be returned with no opening quotes added
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
-    assert first_match is not None and cmd2_app.completion_matches == sorted(food_item_strs)
+    assert first_match is not None and cmd2_app.completion_matches == sorted(food_item_strs,
+                                                                             key=cmd2_app.default_sort_key)
 
 def test_add_opening_quote_basic_nothing_added(cmd2_app):
     text = 'P'
@@ -750,7 +757,7 @@ def test_add_opening_quote_basic_quote_added(cmd2_app):
     endidx = len(line)
     begidx = endidx - len(text)
 
-    expected = sorted(['"Ham', '"Ham Sandwich'])
+    expected = sorted(['"Ham', '"Ham Sandwich'], key=cmd2_app.default_sort_key)
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is not None and cmd2_app.completion_matches == expected
 
@@ -771,7 +778,7 @@ def test_add_opening_quote_basic_text_is_common_prefix(cmd2_app):
     endidx = len(line)
     begidx = endidx - len(text)
 
-    expected = sorted(['"Ham', '"Ham Sandwich'])
+    expected = sorted(['"Ham', '"Ham Sandwich'], key=cmd2_app.default_sort_key)
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is not None and cmd2_app.completion_matches == expected
 
@@ -783,7 +790,8 @@ def test_add_opening_quote_delimited_no_text(cmd2_app):
 
     # The whole list will be returned with no opening quotes added
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
-    assert first_match is not None and cmd2_app.completion_matches == sorted(delimited_strs)
+    assert first_match is not None and cmd2_app.completion_matches == sorted(delimited_strs,
+                                                                             key=cmd2_app.default_sort_key)
 
 def test_add_opening_quote_delimited_nothing_added(cmd2_app):
     text = '/ho'
@@ -791,8 +799,8 @@ def test_add_opening_quote_delimited_nothing_added(cmd2_app):
     endidx = len(line)
     begidx = endidx - len(text)
 
-    expected_matches = sorted(delimited_strs)
-    expected_display = sorted(['other user', 'user'])
+    expected_matches = sorted(delimited_strs, key=cmd2_app.default_sort_key)
+    expected_display = sorted(['other user', 'user'], key=cmd2_app.default_sort_key)
 
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is not None and \
@@ -806,7 +814,7 @@ def test_add_opening_quote_delimited_quote_added(cmd2_app):
     begidx = endidx - len(text)
 
     expected_common_prefix = '"/home/user/file'
-    expected_display = sorted(['file.txt', 'file space.txt'])
+    expected_display = sorted(['file.txt', 'file space.txt'], key=cmd2_app.default_sort_key)
 
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is not None and \
@@ -821,7 +829,7 @@ def test_add_opening_quote_delimited_text_is_common_prefix(cmd2_app):
     begidx = endidx - len(text)
 
     expected_common_prefix = '"/home/user/file'
-    expected_display = sorted(['file.txt', 'file space.txt'])
+    expected_display = sorted(['file.txt', 'file space.txt'], key=cmd2_app.default_sort_key)
 
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is not None and \
@@ -863,6 +871,34 @@ def test_quote_as_command(cmd2_app):
     assert first_match is None and not cmd2_app.completion_matches
 
 
+def test_complete_multiline_on_single_line(cmd2_app):
+    text = ''
+    line = 'test_multiline {}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    expected = sorted(sport_item_strs, key=cmd2_app.default_sort_key)
+    first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+
+    assert first_match is not None and cmd2_app.completion_matches == expected
+
+
+def test_complete_multiline_on_multiple_lines(cmd2_app):
+    # Set the same variables _complete_statement() sets when a user is entering data at a continuation prompt
+    cmd2_app._at_continuation_prompt = True
+    cmd2_app._multiline_in_progress = "test_multiline\n"
+
+    text = 'Ba'
+    line = '{}'.format(text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    expected = sorted(['Bat', 'Basket', 'Basketball'], key=cmd2_app.default_sort_key)
+    first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+
+    assert first_match is not None and cmd2_app.completion_matches == expected
+
+
 # Used by redirect_complete tests
 class RedirCompType(enum.Enum):
     SHELL_CMD = 1,