Repository: matthewwardrop/formulaic

Issue:
BUG: Repeated constant-like terms are incorrectly parsed
Formulas like `0 + 0 + 1 + x` are incorrectly parsed and result in a DataFrame that looks like:

```
from formulaic import model_matrix
from pandas import DataFrame 
df = DataFrame([[0],[1],[2],[3],[4]],columns=["x"])
dep = model_matrix("0 + 0 + 1 + x", df, ensure_full_rank=False)
print(DataFrame(dep))
```

```
   Intercept  Intercept  x
0        0.0        1.0  0
1        0.0        1.0  1
2        0.0        1.0  2
3        0.0        1.0  3
4        0.0        1.0  4
```

`0 + 0 + 1` should evaluate to just `1 +`

Comments:

Patch:
diff --git a/formulaic/materializers/base.py b/formulaic/materializers/base.py
index bc7f7be..dd76082 100644
--- a/formulaic/materializers/base.py
+++ b/formulaic/materializers/base.py
@@ -295,7 +295,7 @@ class FormulaMaterializer(metaclass=InterfaceMeta):
             return values.get('__kind__') == 'categorical'
         return False
 
-    def _check_for_nulls(self, name, values, drop_rows):
+    def _check_for_nulls(self, name, values, na_action, drop_rows):
         pass  # pragma: no cover
 
     def _encode_evaled_factor(self, factor, encoder_state, drop_rows, reduced_rank=False):
diff --git a/formulaic/materializers/pandas.py b/formulaic/materializers/pandas.py
index 943e5cf..d9b9b44 100644
--- a/formulaic/materializers/pandas.py
+++ b/formulaic/materializers/pandas.py
@@ -16,6 +16,7 @@ class PandasMaterializer(FormulaMaterializer):
     REGISTRY_NAME = 'pandas'
     DEFAULT_FOR = ['pandas.core.frame.DataFrame']
 
+    @override
     def _init(self, sparse=False):
         self.config.sparse = sparse
 
diff --git a/formulaic/parser/parser.py b/formulaic/parser/parser.py
index 34e8a8e..dab6bc2 100644
--- a/formulaic/parser/parser.py
+++ b/formulaic/parser/parser.py
@@ -36,7 +36,7 @@ class FormulaParser:
             terms = arg.to_terms()
             if len(terms) > 1 or list(terms)[0] != '0':
                 raise FormulaParsingError("Unary negation is only implemented for '0', where it is substituted for '1'.")
-            return {Term(factors=[Factor('1', eval_method='literal')])}
+            return {Term(factors=[Factor('1', eval_method='literal')])}  # pragma: no cover; All zero handling is currently done in the token pre-processor.
 
         return [
             Operator("~", arity=2, precedence=-100, associativity=None, to_terms=formula_separator_expansion),
@@ -86,11 +86,21 @@ class FormulaParser:
                     tokens.insert(0, one)
                     tokens.insert(1, plus)
 
-        # Replace "0" with "-1"
+        # Replace all "0"s with "-1"
+        zero_index = -1
         try:
-            zero_index = tokens.index('0')
-            if tokens[zero_index - 1] == '+':
-                tokens[zero_index - 1] = minus
+            while True:
+                zero_index = tokens.index('0', zero_index + 1)
+                if zero_index - 1 < 0 or tokens[zero_index - 1] == '~':
+                    tokens.pop(zero_index)
+                    zero_index -= 1
+                    continue
+                elif tokens[zero_index - 1] == '+':
+                    tokens[zero_index - 1] = minus
+                elif tokens[zero_index - 1] == '-':
+                    tokens[zero_index - 1] = plus
+                else:
+                    raise FormulaParsingError(f"Unrecognised use of `0` at index: {tokens[zero_index-1].source_start}.")
                 tokens[zero_index] = one
         except ValueError:
             pass


Test patch:
diff --git a/tests/parser/test_parser.py b/tests/parser/test_parser.py
index a5d4e2f..7b64ae9 100644
--- a/tests/parser/test_parser.py
+++ b/tests/parser/test_parser.py
@@ -18,7 +18,7 @@ FORMULA_TO_TOKENS = {
 
     # Test translation of '0'
     '0': ['1', '-', '1'],
-    '0 ~': ['0', '~', '1']
+    '0 ~': ['~', '1']
 }
 
 FORMULA_TO_TERMS = {
@@ -34,6 +34,13 @@ FORMULA_TO_TERMS = {
     '(a - a) + b': ['1', 'b'],
     'a + (b - a)': ['1', 'a', 'b'],
 
+    # Check that "0" -> "-1" substitution works as expected
+    '0 + 0': [],
+    '0 + 0 + 1': ['1'],
+    '0 + 0 + 1 + 0': [],
+    '0 - 0': ['1'],
+    '0 ~ 0': ([], ),
+
     # Formula separators
     '~ a + b': (['1', 'a', 'b'], ),
     'a ~ b + c': (['a'], ['1', 'b', 'c']),
@@ -80,4 +87,8 @@ class TestFormulaParser:
 
     def test_invalid_unary_negation(self):
         with pytest.raises(FormulaParsingError):
-            assert PARSER.get_terms('(-10)')
+            PARSER.get_terms('(-10)')
+
+    def test_invalid_use_of_zero(self):
+        with pytest.raises(FormulaParsingError):
+            PARSER.get_terms('a * 0')