Repository: xonsh/xonsh

Issue:
Unable to pass a single ">" as an argument
`echo spam ">" eggs` is handled and executed exactly the same as `echo spam > eggs`.

I think this is because of how data is sent to `cmd_to_specs()` eg both are passed as `['echo', 'spam', '>', 'eggs']`.

Also:
* `echo spam @(">") eggs`

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**


Discussion:
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/xonsh/xonsh/pull/5322?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 64.96%. Comparing base [(`7c23508`)](https://app.codecov.io/gh/xonsh/xonsh/commit/7c23508afc1e753701eb36ab03aa63bdfd753cd7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) to head [(`8d24617`)](https://app.codecov.io/gh/xonsh/xonsh/pull/5322?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh).
> Report is 28 commits behind head on main.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5322      +/-   ##
==========================================
- Coverage   67.14%   64.96%   -2.19%     
==========================================
  Files         119      119              
  Lines       23125    23135      +10     
  Branches     4855     4859       +4     
==========================================
- Hits        15528    15029     -499     
- Misses       6391     6866     +475     
- Partials     1206     1240      +34     
```

| [Flag](https://app.codecov.io/gh/xonsh/xonsh/pull/5322/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | Coverage Δ | |
|---|---|---|
| [macOS-latest](https://app.codecov.io/gh/xonsh/xonsh/pull/5322/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | `?` | |
| [ubuntu-latest](https://app.codecov.io/gh/xonsh/xonsh/pull/5322/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | `64.96% <100.00%> (+0.24%)` | :arrow_up: |
| [windows-latest](https://app.codecov.io/gh/xonsh/xonsh/pull/5322/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh) | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/xonsh/xonsh/pull/5322?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=xonsh).


Patch:
diff --git a/news/fix-redirect-structure.rst b/news/fix-redirect-structure.rst
new file mode 100644
index 00000000..a84244a8
--- /dev/null
+++ b/news/fix-redirect-structure.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* <news item>
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* Redirect tokens in quotes (e.g. ">", "2>", "2>1") are now correctly passed to commands as regular arguments.
+
+**Security:**
+
+* <news item>
diff --git a/xonsh/lexer.py b/xonsh/lexer.py
index a0f55feb..ddebd59a 100644
--- a/xonsh/lexer.py
+++ b/xonsh/lexer.py
@@ -23,7 +23,8 @@ from xonsh.tokenize import (
     ERRORTOKEN,
     GREATER,
     INDENT,
-    IOREDIRECT,
+    IOREDIRECT1,
+    IOREDIRECT2,
     LESS,
     MATCH,
     NAME,
@@ -101,7 +102,8 @@ def token_map():
     }
     for op, typ in _op_map.items():
         tm[(OP, op)] = typ
-    tm[IOREDIRECT] = "IOREDIRECT"
+    tm[IOREDIRECT1] = "IOREDIRECT1"
+    tm[IOREDIRECT2] = "IOREDIRECT2"
     tm[STRING] = "STRING"
     tm[DOLLARNAME] = "DOLLAR_NAME"
     tm[NUMBER] = "NUMBER"
@@ -255,7 +257,7 @@ def handle_redirect(state, token):
     key = (typ, st) if (typ, st) in token_map else typ
     new_tok = _new_token(token_map[key], st, token.start)
     if state["pymode"][-1][0]:
-        if typ == IOREDIRECT:
+        if typ in (IOREDIRECT1, IOREDIRECT2):
             # Fix Python mode code that was incorrectly recognized as an
             # IOREDIRECT by the tokenizer (see issue #4994).
             # The tokenizer does not know when the code should be tokenized in
@@ -310,7 +312,8 @@ def special_handlers():
         LESS: handle_redirect,
         GREATER: handle_redirect,
         RIGHTSHIFT: handle_redirect,
-        IOREDIRECT: handle_redirect,
+        IOREDIRECT1: handle_redirect,
+        IOREDIRECT2: handle_redirect,
         (OP, "<"): handle_redirect,
         (OP, ">"): handle_redirect,
         (OP, ">>"): handle_redirect,
diff --git a/xonsh/parsers/base.py b/xonsh/parsers/base.py
index e5ede659..c4a6c524 100644
--- a/xonsh/parsers/base.py
+++ b/xonsh/parsers/base.py
@@ -3432,12 +3432,20 @@ class BaseParser:
 
     def p_subproc_atom_redirect(self, p):
         """
-        subproc_atom : GT
-                     | LT
-                     | RSHIFT
-                     | IOREDIRECT
-        """
-        p0 = ast.const_str(s=p[1], lineno=self.lineno, col_offset=self.col)
+        subproc_atom : GT WS subproc_atom
+                     | LT WS subproc_atom
+                     | RSHIFT WS subproc_atom
+                     | IOREDIRECT1 WS subproc_atom
+                     | IOREDIRECT2
+        """
+        operator = ast.const_str(s=p[1], lineno=self.lineno, col_offset=self.col)
+        elts = [operator] if len(p) == 2 else [operator, p[3]]
+        p0 = ast.Tuple(
+            elts=elts,
+            ctx=ast.Load(),
+            lineno=self.lineno,
+            col_offset=self.col,
+        )
         p0._cliarg_action = "append"
         p[0] = p0
 
@@ -3523,7 +3531,8 @@ class BaseParser:
             "LT",
             "LSHIFT",
             "RSHIFT",
-            "IOREDIRECT",
+            "IOREDIRECT1",
+            "IOREDIRECT2",
             "SEARCHPATH",
             "INDENT",
             "DEDENT",
diff --git a/xonsh/parsers/completion_context.py b/xonsh/parsers/completion_context.py
index 04d34350..0984d967 100644
--- a/xonsh/parsers/completion_context.py
+++ b/xonsh/parsers/completion_context.py
@@ -330,7 +330,8 @@ class CompletionContextParser:
         "LT",
         "GT",
         "RSHIFT",
-        "IOREDIRECT",
+        "IOREDIRECT1",
+        "IOREDIRECT2",
     }
     used_tokens |= io_redir_tokens
     artificial_tokens = {"ANY"}
diff --git a/xonsh/procs/specs.py b/xonsh/procs/specs.py
index 9a0c639f..7c538a1b 100644
--- a/xonsh/procs/specs.py
+++ b/xonsh/procs/specs.py
@@ -172,10 +172,6 @@ def _O2E_MAP():
     return frozenset({f"{o}>{e}" for e in _REDIR_ERR for o in _REDIR_OUT if o != ""})
 
 
-def _is_redirect(x):
-    return isinstance(x, str) and _REDIR_REGEX.match(x)
-
-
 def safe_open(fname, mode, buffering=-1):
     """Safely attempts to open a file in for xonsh subprocs."""
     # file descriptors
@@ -401,7 +397,7 @@ class SubprocSpec:
         else:
             safe_close(value)
             msg = "Multiple inputs for stdin for {0!r}"
-            msg = msg.format(" ".join(self.args))
+            msg = msg.format(self.get_command_str())
             raise xt.XonshError(msg)
 
     @property
@@ -417,7 +413,7 @@ class SubprocSpec:
         else:
             safe_close(value)
             msg = "Multiple redirections for stdout for {0!r}"
-            msg = msg.format(" ".join(self.args))
+            msg = msg.format(self.get_command_str())
             raise xt.XonshError(msg)
 
     @property
@@ -433,9 +429,14 @@ class SubprocSpec:
         else:
             safe_close(value)
             msg = "Multiple redirections for stderr for {0!r}"
-            msg = msg.format(" ".join(self.args))
+            msg = msg.format(self.get_command_str())
             raise xt.XonshError(msg)
 
+    def get_command_str(self):
+        return " ".join(
+            " ".join(arg) if isinstance(arg, tuple) else arg for arg in self.args
+        )
+
     #
     # Execution methods
     #
@@ -579,8 +580,7 @@ class SubprocSpec:
         spec = kls(cmd, cls=cls, **kwargs)
         # modifications that alter cmds must come after creating instance
         # perform initial redirects
-        spec.redirect_leading()
-        spec.redirect_trailing()
+        spec.resolve_redirects()
         # apply aliases
         spec.resolve_alias()
         spec.resolve_binary_loc()
@@ -590,26 +590,16 @@ class SubprocSpec:
         spec.resolve_stack()
         return spec
 
-    def redirect_leading(self):
-        """Manage leading redirects such as with '< input.txt COMMAND'."""
-        while len(self.cmd) >= 3 and self.cmd[0] == "<":
-            self.stdin = safe_open(self.cmd[1], "r")
-            self.cmd = self.cmd[2:]
-
-    def redirect_trailing(self):
-        """Manages trailing redirects."""
-        while True:
-            cmd = self.cmd
-            if len(cmd) >= 3 and _is_redirect(cmd[-2]):
-                streams = _redirect_streams(cmd[-2], cmd[-1])
-                self.stdin, self.stdout, self.stderr = streams
-                self.cmd = cmd[:-2]
-            elif len(cmd) >= 2 and _is_redirect(cmd[-1]):
-                streams = _redirect_streams(cmd[-1])
+    def resolve_redirects(self):
+        """Manages redirects."""
+        new_cmd = []
+        for c in self.cmd:
+            if isinstance(c, tuple):
+                streams = _redirect_streams(*c)
                 self.stdin, self.stdout, self.stderr = streams
-                self.cmd = cmd[:-1]
             else:
-                break
+                new_cmd.append(c)
+        self.cmd = new_cmd
 
     def resolve_alias(self):
         """Sets alias in command, if applicable."""
@@ -667,8 +657,7 @@ class SubprocSpec:
         else:
             self.cmd = alias + self.cmd[1:]
             # resolve any redirects the aliases may have applied
-            self.redirect_leading()
-            self.redirect_trailing()
+            self.resolve_redirects()
         if self.binary_loc is None:
             return
         try:
diff --git a/xonsh/tokenize.py b/xonsh/tokenize.py
index 5127286c..a31fef8a 100644
--- a/xonsh/tokenize.py
+++ b/xonsh/tokenize.py
@@ -110,7 +110,8 @@ __all__ = token.__all__ + [  # type:ignore
     "ATDOLLAR",
     "ATEQUAL",
     "DOLLARNAME",
-    "IOREDIRECT",
+    "IOREDIRECT1",
+    "IOREDIRECT2",
     "MATCH",
     "CASE",
 ]
@@ -135,8 +136,11 @@ N_TOKENS += 3
 SEARCHPATH = N_TOKENS
 tok_name[N_TOKENS] = "SEARCHPATH"
 N_TOKENS += 1
-IOREDIRECT = N_TOKENS
-tok_name[N_TOKENS] = "IOREDIRECT"
+IOREDIRECT1 = N_TOKENS
+tok_name[N_TOKENS] = "IOREDIRECT1"
+N_TOKENS += 1
+IOREDIRECT2 = N_TOKENS
+tok_name[N_TOKENS] = "IOREDIRECT2"
 N_TOKENS += 1
 DOLLARNAME = N_TOKENS
 tok_name[N_TOKENS] = "DOLLARNAME"
@@ -335,10 +339,11 @@ _redir_map = (
 )
 IORedirect = group(group(*_redir_map), f"{group(*_redir_names)}>>?")
 
-_redir_check_0 = set(_redir_map)
-_redir_check_1 = {f"{i}>" for i in _redir_names}.union(_redir_check_0)
+_redir_check_map = frozenset(_redir_map)
+
+_redir_check_1 = {f"{i}>" for i in _redir_names}
 _redir_check_2 = {f"{i}>>" for i in _redir_names}.union(_redir_check_1)
-_redir_check = frozenset(_redir_check_2)
+_redir_check_single = frozenset(_redir_check_2)
 
 Operator = group(
     r"\*\*=?",
@@ -1004,8 +1009,10 @@ def _tokenize(readline, encoding, tolerant=False, tokenize_ioredirects=True):
                     continue
                 token, initial = line[start:end], line[start]
 
-                if token in _redir_check:
-                    yield TokenInfo(IOREDIRECT, token, spos, epos, line)
+                if token in _redir_check_single:
+                    yield TokenInfo(IOREDIRECT1, token, spos, epos, line)
+                elif token in _redir_check_map:
+                    yield TokenInfo(IOREDIRECT2, token, spos, epos, line)
                 elif initial in numchars or (  # ordinary number
                     initial == "." and token != "." and token != "..."
                 ):


Test patch:
diff --git a/tests/test_integrations.py b/tests/test_integrations.py
index 84cdecba..d689a291 100644
--- a/tests/test_integrations.py
+++ b/tests/test_integrations.py
@@ -886,6 +886,27 @@ aliases['echo'] = _echo
     assert out == exp
 
 
+@skip_if_no_xonsh
+@pytest.mark.parametrize(
+    "cmd, exp",
+    [
+        ("echo '>'", ">\n"),
+        ("echo '2>'", "2>\n"),
+        ("echo '2>1'", "2>1\n"),
+    ],
+)
+def test_redirect_argument(cmd, exp):
+    script = f"""
+#!/usr/bin/env xonsh
+def _echo(args):
+    print(' '.join(args))
+aliases['echo'] = _echo
+{cmd}
+"""
+    out, _, _ = run_xonsh(script)
+    assert out == exp
+
+
 # issue 3402
 @skip_if_no_xonsh
 @skip_if_on_windows
diff --git a/tests/test_lexer.py b/tests/test_lexer.py
index c707c1cc..ad209655 100644
--- a/tests/test_lexer.py
+++ b/tests/test_lexer.py
@@ -415,11 +415,14 @@ def test_float_literals(case):
     assert check_token(case, ["NUMBER", case, 0])
 
 
-@pytest.mark.parametrize(
-    "case", ["2>1", "err>out", "o>", "all>", "e>o", "e>", "out>", "2>&1"]
-)
-def test_ioredir(case):
-    assert check_tokens_subproc(case, [("IOREDIRECT", case, 2)], stop=-2)
+@pytest.mark.parametrize("case", ["o>", "all>", "e>", "out>"])
+def test_ioredir1(case):
+    assert check_tokens_subproc(case, [("IOREDIRECT1", case, 2)], stop=-2)
+
+
+@pytest.mark.parametrize("case", ["2>1", "err>out", "e>o", "2>&1"])
+def test_ioredir2(case):
+    assert check_tokens_subproc(case, [("IOREDIRECT2", case, 2)], stop=-2)
 
 
 @pytest.mark.parametrize("case", [">", ">>", "<", "e>", "> ", ">>   ", "<  ", "e> "])