Repository: alvinwan/TexSoup

Issue:
Control spaces "\ " in math mode don't make the roundtrip
People often use `\ ` (called control space) to insert a little extra space in math mode—to avoid the default behaviour of ignoring math spaces.

In TexSoup these get eaten up somehow, so when serialize back out, the tex is different:

Failing test case:
```python
def test_contol_space_command():
    """A control space "\ " can be used to insert extra spacing in math mode."""
    math_with_spaces = r"""$a \ = \ \sin\theta$"""
    soup = TexSoup(math_with_spaces)
    assert str(soup) == math_with_spaces, 'Control spaces not preserved in math'
```

<img width="561" alt="Screen Shot 2020-10-18 at 1 10 39 AM" src="https://user-images.githubusercontent.com/163966/96359259-cb07c400-10de-11eb-9364-228d308bc34b.png">

expected: `$a \ = \ \sin\theta$`

observed: `$a \=\\sin\theta$`

Here are the tokens in case that's helpful:
```
  '$'_MathSwitch
  'a '_Text
  '\'_Escape
  ' = '_Text
  '\'_Escape
  ' '_MergedSpacer
  '\'_Escape
  'sin'_CommandName
  '\'_Escape
  'theta'_CommandName
  '$'_MathSwitch
```


See https://tex.stackexchange.com/a/74354 for a complete list of all special LaTeX spacing commands inside and outide of math mode.

No urgency to fix this --- perhaps it is even a feature, since prevents users from trying to override default LaTeX spacing behaviour :)


Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/44754315/badge)](https://coveralls.io/builds/44754315)

Coverage remained the same at 100.0% when pulling **8d68a717e4cb3876f2bb269ddf781224f6719a71 on masonproffitt:fix_117_retain-control-spaces** into **f91d4e71b21aa6852378d2d60ecc551b39e05bf0 on alvinwan:master**.


Patch:
diff --git a/TexSoup/data.py b/TexSoup/data.py
index f7279c8..58cd070 100644
--- a/TexSoup/data.py
+++ b/TexSoup/data.py
@@ -1317,7 +1317,7 @@ class TexArgs(list):
         """
         arg = self.__coerce(arg)
 
-        if isinstance(arg, TexGroup):
+        if isinstance(arg, (TexGroup, TexCmd)):
             super().insert(i, arg)
 
         if len(self) <= 1:
diff --git a/TexSoup/reader.py b/TexSoup/reader.py
index 3347393..fd53989 100644
--- a/TexSoup/reader.py
+++ b/TexSoup/reader.py
@@ -391,7 +391,7 @@ def read_arg_required(
        b. A curly-brace delimiter. If the required argument is brace-delimited,
           the contents of the brace group are used as the argument.
        c. Spacer or not, if a brace group is not found, simply use the next
-          character.
+          character, unless it is a backslash, in which case use the full command name
 
     :param Buffer src: a buffer of tokens
     :param TexArgs args: existing arguments to extend
@@ -422,7 +422,12 @@ def read_arg_required(
             n_required -= 1
             continue
         elif src.hasNext() and n_required > 0:
-            args.append('{%s}' % next(src))
+            next_token = next(src)
+            if next_token.category == TC.Escape:
+                name, _ = read_command(src, 0, 0, tolerance=tolerance, mode=mode)
+                args.append(TexCmd(name, position=next_token.position))
+            else:
+                args.append('{%s}' % next_token)
             n_required -= 1
             continue
 
diff --git a/TexSoup/tokens.py b/TexSoup/tokens.py
index 1f66cd5..492c50e 100644
--- a/TexSoup/tokens.py
+++ b/TexSoup/tokens.py
@@ -116,14 +116,15 @@ def tokenize_escaped_symbols(text, prev=None):
     '\\}'
     >>> tokenize_escaped_symbols(categorize(r'\%'))
     '\\%'
-    >>> tokenize_escaped_symbols(categorize(r'\ %'))  # not even one spacer is allowed
+    >>> tokenize_escaped_symbols(categorize(r'\ '))
+    '\\ '
     """
     if text.peek().category == CC.Escape \
             and text.peek(1) \
             and text.peek(1).category in (
                 CC.Escape, CC.GroupBegin, CC.GroupEnd, CC.MathSwitch,
                 CC.Alignment, CC.Macro, CC.Superscript, CC.Subscript,
-                CC.Active, CC.Comment, CC.Other):
+                CC.Spacer, CC.Active, CC.Comment, CC.Other):
         result = text.forward(2)
         result.category = TC.EscapedComment
         return result


Test patch:
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 136710e..60ea596 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -189,9 +189,9 @@ def test_escaped_characters():
     """
     soup = TexSoup(r"""
     \begin{itemize}
-    \item Ice cream costs \$4-\$5 around here. \}[\{]
+    \item Ice cream costs \$4-\$5 around here. \}\ [\{]
     \end{itemize}""")
-    assert str(soup.item).strip() == r'\item Ice cream costs \$4-\$5 around here. \}[\{]'
+    assert str(soup.item).strip() == r'\item Ice cream costs \$4-\$5 around here. \}\ [\{]'
     assert '\\$4-\\$5' in str(soup), 'Escaped characters not properly rendered.'
 
 
@@ -384,6 +384,14 @@ def test_def_item():
     assert soup.item is not None
 
 
+def test_def_without_braces():
+    """Tests that def without braces around the new command parses correctly"""
+    soup = TexSoup(r"\def\acommandname{replacement text}")
+    assert len(soup.find("def").args) == 2
+    assert str(soup.find("def").args[0]) == r"\acommandname"
+    assert str(soup.find("def").args[1]) == "{replacement text}"
+
+
 def test_grouping_optional_argument():
     """Tests that grouping occurs correctly"""
     soup = TexSoup(r"\begin{Theorem}[The argopt contains {$]\int_\infty$} the square bracket]\end{Theorem}")