Repository: python-cmd2/cmd2
Pull request #707 (2019-07-01 03:21:55)

Redirect complete
Rewrote _redirect_complete to match our parsing behavior


Patch:
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index d41a631d..97fb8cd0 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -1200,7 +1200,7 @@ class Cmd(cmd.Cmd):
 
     def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: Callable) -> List[str]:
         """Called by complete() as the first tab completion function for all commands
-        It determines if it should tab complete for redirection (|, <, >, >>) or use the
+        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)
@@ -1219,28 +1219,58 @@ class Cmd(cmd.Cmd):
             if not raw_tokens:
                 return []
 
+            # Must at least have the command
             if len(raw_tokens) > 1:
 
-                # Check if there are redirection strings prior to the token being completed
-                seen_pipe = False
+                # True when command line contains any redirection tokens
                 has_redirection = False
 
-                for cur_token in raw_tokens[:-1]:
+                # Keep track of state while examining tokens
+                in_pipe = False
+                in_file_redir = False
+                do_shell_completion = False
+                do_path_completion = False
+                prior_token = None
+
+                for cur_token in raw_tokens:
+                    # Process redirection tokens
                     if cur_token in constants.REDIRECTION_TOKENS:
                         has_redirection = True
 
+                        # Check if we are at a pipe
                         if cur_token == constants.REDIRECTION_PIPE:
-                            seen_pipe = True
+                            # Do not complete bad syntax (e.g cmd | |)
+                            if prior_token == constants.REDIRECTION_PIPE:
+                                return []
+
+                            in_pipe = True
+                            in_file_redir = False
+
+                        # Otherwise this is a file redirection token
+                        else:
+                            if prior_token in constants.REDIRECTION_TOKENS or in_file_redir:
+                                # Do not complete bad syntax (e.g cmd | >) (e.g cmd > blah >)
+                                return []
+
+                            in_pipe = False
+                            in_file_redir = True
+
+                    # Not a redirection token
+                    else:
+                        do_shell_completion = False
+                        do_path_completion = False
+
+                        if prior_token == constants.REDIRECTION_PIPE:
+                            do_shell_completion = True
+                        elif in_pipe or prior_token in (constants.REDIRECTION_OUTPUT, constants.REDIRECTION_APPEND):
+                            do_path_completion = True
 
-                # Get token prior to the one being completed
-                prior_token = raw_tokens[-2]
+                    prior_token = cur_token
 
-                # If a pipe is right before the token being completed, complete a shell command as the piped process
-                if prior_token == constants.REDIRECTION_PIPE:
+                if do_shell_completion:
                     return self.shell_cmd_complete(text, line, begidx, endidx)
 
-                # Otherwise do path completion either as files to redirectors or arguments to the piped process
-                elif prior_token in constants.REDIRECTION_TOKENS or seen_pipe:
+                elif do_path_completion:
                     return self.path_complete(text, line, begidx, endidx)
 
                 # If there were redirection strings anywhere on the command line, then we


Test patch:
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 9157ce84..eea34ba6 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -6,15 +6,23 @@ Unit/functional testing for readline tab-completion functions in the cmd2.py mod
 These are primarily tests related to readline completer functions which handle tab-completion of cmd2/cmd commands,
 file system paths, and shell commands.
 """
+# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available
+try:
+    import mock
+except ImportError:
+    from unittest import mock
+
 import argparse
+import enum
 import os
 import sys
 
 import pytest
+
 import cmd2
 from cmd2 import utils
-from .conftest import base_app, complete_tester, normalize, run_cmd
 from examples.subcommands import SubcommandsExample
+from .conftest import complete_tester, normalize, run_cmd
 
 # List of strings used with completion functions
 food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato', 'Cheese "Pizza"']
@@ -854,6 +862,71 @@ def test_quote_as_command(cmd2_app):
     first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
     assert first_match is None and not cmd2_app.completion_matches
 
+
+# Used by redirect_complete tests
+class RedirCompType(enum.Enum):
+    SHELL_CMD = 1,
+    PATH = 2,
+    DEFAULT = 3,
+    NONE = 4
+
+    """   
+    fake > > 
+    fake | grep > file 
+    fake | grep > file > 
+    """
+
+@pytest.mark.parametrize('line, comp_type', [
+    ('fake', RedirCompType.DEFAULT),
+    ('fake arg', RedirCompType.DEFAULT),
+    ('fake |', RedirCompType.SHELL_CMD),
+    ('fake | grep', RedirCompType.PATH),
+    ('fake | grep arg', RedirCompType.PATH),
+    ('fake | grep >', RedirCompType.PATH),
+    ('fake | grep > >', RedirCompType.NONE),
+    ('fake | grep > file', RedirCompType.NONE),
+    ('fake | grep > file >', RedirCompType.NONE),
+    ('fake | grep > file |', RedirCompType.SHELL_CMD),
+    ('fake | grep > file | grep', RedirCompType.PATH),
+    ('fake | |', RedirCompType.NONE),
+    ('fake | >', RedirCompType.NONE),
+    ('fake >', RedirCompType.PATH),
+    ('fake >>', RedirCompType.PATH),
+    ('fake > >', RedirCompType.NONE),
+    ('fake > |', RedirCompType.SHELL_CMD),
+    ('fake >> file |', RedirCompType.SHELL_CMD),
+    ('fake >> file | grep', RedirCompType.PATH),
+    ('fake > file', RedirCompType.NONE),
+    ('fake > file >', RedirCompType.NONE),
+    ('fake > file >>', RedirCompType.NONE),
+])
+def test_redirect_complete(cmd2_app, monkeypatch, line, comp_type):
+    shell_cmd_complete_mock = mock.MagicMock(name='shell_cmd_complete')
+    monkeypatch.setattr("cmd2.Cmd.shell_cmd_complete", shell_cmd_complete_mock)
+
+    path_complete_mock = mock.MagicMock(name='path_complete')
+    monkeypatch.setattr("cmd2.Cmd.path_complete", path_complete_mock)
+
+    default_complete_mock = mock.MagicMock(name='fake_completer')
+
+    text = ''
+    line = '{} {}'.format(line, text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    cmd2_app._redirect_complete(text, line, begidx, endidx, default_complete_mock)
+
+    if comp_type == RedirCompType.SHELL_CMD:
+        shell_cmd_complete_mock.assert_called_once()
+    elif comp_type == RedirCompType.PATH:
+        path_complete_mock.assert_called_once()
+    elif comp_type == RedirCompType.DEFAULT:
+        default_complete_mock.assert_called_once()
+    else:
+        shell_cmd_complete_mock.assert_not_called()
+        path_complete_mock.assert_not_called()
+        default_complete_mock.assert_not_called()
+
 @pytest.fixture
 def sc_app():
     c = SubcommandsExample()