Repository: matthewwardrop/formulaic

Issue:
BUG: SyntaxError converted to KeyError
I'm not sure if this should be considered syntax error, but if I try the formula

```
import pandas as pd
import numpy as np
df = pd.DataFrame(np.random.standard_normal((100,3)),columns=["y","y2","x"])
from formulaic import model_matrix
dep = model_matrix("y y2 ~ 1 + x", df, ensure_full_rank=False)
```

I get `KeyError: 'yy2'` which suggests the white space ie being trimmed.  FWIW patsy's tokenizer would raise SyntaxError on this.


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=h1) Report
> Merging [#15](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=desc) into [master](https://codecov.io/gh/matthewwardrop/formulaic/commit/7afb4e4029860f081e16473621595e2c47634933&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/graphs/tree.svg?width=650&height=150&src=pr&token=3I8PW2O6IN)](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master       #15   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           28        30    +2     
  Lines         1102      1169   +67     
=========================================
+ Hits          1102      1169   +67     
```

| Flag | Coverage Δ | |
|---|---|---|
| #unittests | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [formulaic/errors.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL2Vycm9ycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [formulaic/parser/algos/infix\_to\_ast.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci9hbGdvcy9pbmZpeF90b19hc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [formulaic/parser/algos/tokenize.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci9hbGdvcy90b2tlbml6ZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [formulaic/parser/parser.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci9wYXJzZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [formulaic/parser/types/operator\_resolver.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci90eXBlcy9vcGVyYXRvcl9yZXNvbHZlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [formulaic/parser/types/token.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci90eXBlcy90b2tlbi5weQ==) | `100.00% <100.00%> (ø)` | |
| [formulaic/parser/utils.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci91dGlscy5weQ==) | `100.00% <100.00%> (ø)` | |
| [formulaic/formula.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL2Zvcm11bGEucHk=) | `100.00% <0.00%> (ø)` | |
| [formulaic/model\_spec.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL21vZGVsX3NwZWMucHk=) | `100.00% <0.00%> (ø)` | |
| [formulaic/parser/types/term.py](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree#diff-Zm9ybXVsYWljL3BhcnNlci90eXBlcy90ZXJtLnB5) | `100.00% <0.00%> (ø)` | |
| ... and [10 more](https://codecov.io/gh/matthewwardrop/formulaic/pull/15/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=footer). Last update [7afb4e4...13df0a1](https://codecov.io/gh/matthewwardrop/formulaic/pull/15?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/formulaic/errors.py b/formulaic/errors.py
index bed229e..b7e0b86 100644
--- a/formulaic/errors.py
+++ b/formulaic/errors.py
@@ -13,13 +13,25 @@ class FormulaicWarning(Warning):
 
 
 class FormulaInvalidError(FormulaicError):
+    """
+    Provided formula specification is not a valid format.
+    """
     pass
 
 
 class FormulaParsingError(FormulaicError):
+    """
+    An error occured during the parsing of a formula specification.
+    """
     pass
 
 
+class FormulaSyntaxError(FormulaParsingError):
+    """
+    Could not tokenize the nominated formula specification.
+    """
+    pass
+
 # Formula materializer meta-errors
 
 
diff --git a/formulaic/parser/__init__.py b/formulaic/parser/__init__.py
index bc06ecc..4ff195f 100644
--- a/formulaic/parser/__init__.py
+++ b/formulaic/parser/__init__.py
@@ -1,5 +1,6 @@
-from .parser import FormulaParser
+from .parser import FormulaParser, DefaultOperatorResolver
 
 __all__ = [
-    'FormulaParser'
+    'FormulaParser',
+    'DefaultOperatorResolver',
 ]
diff --git a/formulaic/parser/algos/infix_to_ast.py b/formulaic/parser/algos/infix_to_ast.py
index 5bdc5e7..df218d7 100644
--- a/formulaic/parser/algos/infix_to_ast.py
+++ b/formulaic/parser/algos/infix_to_ast.py
@@ -1,22 +1,41 @@
-from collections import defaultdict
+from collections import namedtuple
 
-from ..types import Operator, ASTNode, Token
+from ..types import Operator, ASTNode
+from ..utils import exc_for_token, exc_for_missing_operator
 
 
-def infix_to_ast(tokens, operators):
+OrderedOperator = namedtuple('OrderedOperator', ('operator', 'token', 'index'))
 
-    operator_table = defaultdict(dict)
-    for operator in operators:
-        operator_table[operator.arity][operator.symbol] = operator
+
+def infix_to_ast(tokens, operator_resolver):
 
     output_queue = []
     operator_stack = []
-    last_token = None
 
-    def operate(operator, output_queue):
+    def stack_operator(operator, token):
+        operator_stack.append(OrderedOperator(operator, token, len(output_queue)))
+
+    def operate(ordered_operator, output_queue):
+        operator, token, index = ordered_operator
+
+        if operator.fixity is Operator.Fixity.INFIX:
+            assert operator.arity == 2
+            min_index = index - 1
+            max_index = index + 1
+        elif operator.fixity is Operator.Fixity.PREFIX:
+            min_index = index
+            max_index = index + operator.arity
+        else:  # Operator.Fixity.POSTFIX
+            min_index = index - operator.arity
+            max_index = index
+
+        if min_index < 0 or max_index > len(output_queue):
+            raise exc_for_token(token, f"Operator `{token.token}` has insuffient arguments and/or is misplaced.")
+
         return [
-            *output_queue[:-operator.arity],
-            ASTNode(operator, output_queue[-operator.arity:])
+            *output_queue[:min_index],
+            ASTNode(operator, output_queue[min_index:max_index]),
+            *output_queue[max_index:],
         ]
 
     for token in tokens:
@@ -24,27 +43,34 @@ def infix_to_ast(tokens, operators):
             output_queue.append(token)
         else:
             if token.token == '(':
-                operator_stack.append(token)
+                stack_operator(token, token)
             elif token.token == ')':
-                while operator_stack and operator_stack[-1] != '(':
+                while operator_stack and operator_stack[-1].token != '(':
                     output_queue = operate(operator_stack.pop(), output_queue)
-                if operator_stack and operator_stack[-1] == '(':
-                    operator_stack.pop(-1)
+                if operator_stack and operator_stack[-1].token == '(':
+                    operator_stack.pop()
+                else:
+                    raise exc_for_token(token, "Could not find matching parenthesis.")
             else:
-                target_arity = 1 if last_token is None or last_token.kind is Token.Kind.OPERATOR and last_token.token not in ')]' else 2
-                operator = operator_table[target_arity][token.token]
+                max_prefix_arity = len(output_queue) - operator_stack[-1].index if operator_stack else len(output_queue)
+                operators = operator_resolver.resolve(token, max_prefix_arity)
 
-                while target_arity == 2 and operator_stack and (operator_stack[-1] != '(') and (
-                        operator_stack[-1].precedence > operator.precedence
-                        or operator_stack[-1].precedence == operator.precedence and operator.associativity is Operator.Associativity.LEFT
-                ):
-                    output_queue = operate(operator_stack.pop(), output_queue)
+                for operator in operators:
+
+                    while operator_stack and (operator_stack[-1].token != '(') and (
+                            operator_stack[-1].operator.precedence > operator.precedence
+                            or operator_stack[-1].operator.precedence == operator.precedence and operator.associativity is Operator.Associativity.LEFT
+                    ):
+                        output_queue = operate(operator_stack.pop(), output_queue)
 
-                operator_stack.append(operator)
-        last_token = token
+                    stack_operator(operator, token)
 
     while operator_stack:
+        if operator_stack[-1].token == '(':
+            raise exc_for_token(operator_stack[-1].token, "Could not find matching parenthesis.")
         output_queue = operate(operator_stack.pop(), output_queue)
 
     if output_queue:
+        if len(output_queue) > 1:
+            raise exc_for_missing_operator(output_queue[0], output_queue[1])
         return output_queue[0]
diff --git a/formulaic/parser/algos/tokenize.py b/formulaic/parser/algos/tokenize.py
index 434d29c..12e9ff9 100644
--- a/formulaic/parser/algos/tokenize.py
+++ b/formulaic/parser/algos/tokenize.py
@@ -1,8 +1,7 @@
 import re
 
-from formulaic.errors import FormulaParsingError
-
 from ..types import Token
+from ..utils import exc_for_token
 
 
 def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compile(r'[0-9\.]'), whitespace_chars=re.compile(r'\s')):
@@ -22,8 +21,12 @@ def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compi
             continue
         if quote_context and quote_context[-1] in '}`' and char == quote_context[-1]:
             quote_context.pop(-1)
-            yield token
-            token = Token(source=formula)
+            if token:
+                if quote_context:
+                    token.update(char, i)
+                else:
+                    yield token
+                    token = Token(source=formula)
             continue
         if quote_context and char == quote_context[-1]:
             token.update(char, i)
@@ -33,19 +36,24 @@ def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compi
                 token = Token(source=formula)
             continue
         if quote_context and quote_context[-1] in ('"', "'", "`", "}"):
+            if char == '`' and quote_context[-1] == "}":
+                quote_context.append('`')
             token.update(char, i)
             continue
 
         if char == '{':
             if token:
                 yield token
-            token = Token(source=formula, kind='python')
+            token = Token(source=formula, kind='python', source_start=i)
             quote_context.append('}')
             continue
         if char == '`':
-            if token:
-                yield token
-            token = Token(source=formula, kind='name')
+            if token.kind is Token.Kind.PYTHON:
+                token.update(char, i)
+            else:
+                if token:
+                    yield token
+                token = Token(source=formula, kind='name', source_start=i)
             quote_context.append('`')
             continue
         if char in '([':
@@ -62,7 +70,7 @@ def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compi
             if token:
                 yield token
                 token = Token(source=formula)
-            yield Token().update(char, i, kind='operator')
+            yield Token(source=formula).update(char, i, kind='operator')
             continue
 
         if token.kind is Token.Kind.PYTHON:
@@ -70,6 +78,9 @@ def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compi
             continue
 
         if whitespace_chars.match(char):
+            if token and token.kind is not Token.Kind.OPERATOR:
+                yield token
+                token = Token(source=formula)
             continue
 
         if char in ('"', "'"):
@@ -80,7 +91,7 @@ def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compi
                 token.update(char, i, kind='value')
                 quote_context.append(char)
             else:
-                raise FormulaParsingError(f"Unexpected character {repr(char)} following token '{token}'.")
+                raise exc_for_token(Token(source=formula, source_start=i, source_end=i), f"Unexpected character {repr(char)} following token `{token.token}`.")
             continue  # pragma: no cover; workaround bug in coverage
 
         if word_chars.match(char):
@@ -99,5 +110,7 @@ def tokenize(formula, word_chars=re.compile(r'[\.\_\w]'), numeric_chars=re.compi
                 yield token
                 token = Token(source=formula)
             token.update(char, i, kind='operator')
+    if quote_context:
+        raise exc_for_token(token, message=f"Formula ended before quote context was closed. Expected: {quote_context[-1]}")
     if token:
         yield token
diff --git a/formulaic/parser/parser.py b/formulaic/parser/parser.py
index dab6bc2..eb27b5b 100644
--- a/formulaic/parser/parser.py
+++ b/formulaic/parser/parser.py
@@ -1,67 +1,21 @@
+import ast
 import itertools
 import functools
+import re
+from typing import List
 
 from formulaic.errors import FormulaParsingError
 
 from .algos.infix_to_ast import infix_to_ast
 from .algos.tokenize import tokenize
-from .types import Factor, Term, Token, Operator
+from .types import Factor, Term, Token, Operator, OperatorResolver
+from .utils import exc_for_token
 
 
 class FormulaParser:
 
-    @property
-    def operators(self):
-
-        def formula_separator_expansion(lhs, rhs):
-            terms = (lhs.to_terms(), rhs.to_terms())
-
-            out = []
-            for termset in terms:
-                if isinstance(termset, tuple):
-                    out.extend(termset)
-                else:
-                    out.append(termset)
-            return tuple(out)
-
-        def nested_product_expansion(parents, nested):
-            terms = parents.to_terms()
-            common = functools.reduce(lambda x, y: x * y, terms)
-            return terms.union({
-                common * term
-                for term in nested.to_terms()
-            })
-
-        def unary_negation(arg):
-            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')])}  # 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),
-            Operator("~", arity=1, precedence=-100, associativity=None, fixity='prefix', to_terms=lambda expr: (expr.to_terms(), )),
-            Operator("+", arity=2, precedence=100, associativity='left', to_terms=lambda *args: set(itertools.chain(*[arg.to_terms() for arg in args]))),
-            Operator("-", arity=2, precedence=100, associativity='left', to_terms=lambda left, right: set(set(left.to_terms()).difference(right.to_terms()))),
-            Operator("+", arity=1, precedence=100, associativity='right', fixity='prefix', to_terms=lambda arg: arg.to_terms()),
-            Operator("-", arity=1, precedence=100, associativity='right', fixity='prefix', to_terms=unary_negation),
-            Operator("*", arity=2, precedence=200, associativity='left', to_terms=lambda *args: (
-                {
-                    functools.reduce(lambda x, y: x * y, term)
-                    for term in itertools.product(*[arg.to_terms() for arg in args])
-                }
-                .union(itertools.chain(*[arg.to_terms() for arg in args]))
-            )),
-            Operator("/", arity=2, precedence=200, associativity='left', to_terms=nested_product_expansion),
-            Operator(":", arity=2, precedence=300, associativity='left', to_terms=lambda *args: {
-                functools.reduce(lambda x, y: x * y, term)
-                for term in itertools.product(*[arg.to_terms() for arg in args])
-            }),
-            Operator("**", arity=2, precedence=500, associativity='right', to_terms=lambda arg, power: {
-                functools.reduce(lambda x, y: x * y, term)
-                for term in itertools.product(*[arg.to_terms()] * int(power.token))
-            }),
-        ]
+    def __init__(self, operator_resolver=None):
+        self.operator_resolver = operator_resolver or DefaultOperatorResolver()
 
     def get_tokens(self, formula, *, include_intercept=True):
         tokens = list(tokenize(formula))
@@ -108,7 +62,7 @@ class FormulaParser:
         return tokens
 
     def get_ast(self, formula, *, include_intercept=True):
-        return infix_to_ast(self.get_tokens(formula, include_intercept=include_intercept), operators=self.operators)
+        return infix_to_ast(self.get_tokens(formula, include_intercept=include_intercept), operator_resolver=self.operator_resolver)
 
     def get_terms(self, formula, *, sort=True, include_intercept=True):
         terms = self.get_ast(formula, include_intercept=include_intercept).to_terms()
@@ -120,3 +74,83 @@ class FormulaParser:
                 terms = sorted(terms)
 
         return terms
+
+
+class DefaultOperatorResolver(OperatorResolver):
+
+    @property
+    def operators(self):
+
+        def formula_separator_expansion(lhs, rhs):
+            terms = (lhs.to_terms(), rhs.to_terms())
+
+            out = []
+            for termset in terms:
+                if isinstance(termset, tuple):
+                    out.extend(termset)
+                else:
+                    out.append(termset)
+            return tuple(out)
+
+        def nested_product_expansion(parents, nested):
+            terms = parents.to_terms()
+            common = functools.reduce(lambda x, y: x * y, terms)
+            return terms.union({
+                common * term
+                for term in nested.to_terms()
+            })
+
+        def unary_negation(arg):
+            # TODO: FormulaParser().get_terms('a * ( - b)') Should return `a`
+            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')])}  # pragma: no cover; All zero handling is currently done in the token pre-processor.
+
+        def power(arg, power):
+            if not isinstance(power, Token) or power.kind is not Token.Kind.VALUE or not isinstance(ast.literal_eval(power.token), int):
+                raise exc_for_token(power, "The right-hand argument of `**` must be a positive integer.")
+            return {
+                functools.reduce(lambda x, y: x * y, term)
+                for term in itertools.product(*[arg.to_terms()] * int(power.token))
+            }
+
+        return [
+            Operator("~", arity=2, precedence=-100, associativity=None, to_terms=formula_separator_expansion),
+            Operator("~", arity=1, precedence=-100, associativity=None, fixity='prefix', to_terms=lambda expr: (expr.to_terms(), )),
+            Operator("+", arity=2, precedence=100, associativity='left', to_terms=lambda *args: set(itertools.chain(*[arg.to_terms() for arg in args]))),
+            Operator("-", arity=2, precedence=100, associativity='left', to_terms=lambda left, right: set(set(left.to_terms()).difference(right.to_terms()))),
+            Operator("+", arity=1, precedence=100, associativity='right', fixity='prefix', to_terms=lambda arg: arg.to_terms()),
+            Operator("-", arity=1, precedence=100, associativity='right', fixity='prefix', to_terms=unary_negation),
+            Operator("*", arity=2, precedence=200, associativity='left', to_terms=lambda *args: (
+                {
+                    functools.reduce(lambda x, y: x * y, term)
+                    for term in itertools.product(*[arg.to_terms() for arg in args])
+                }
+                .union(itertools.chain(*[arg.to_terms() for arg in args]))
+            )),
+            Operator("/", arity=2, precedence=200, associativity='left', to_terms=nested_product_expansion),
+            Operator(":", arity=2, precedence=300, associativity='left', to_terms=lambda *args: {
+                functools.reduce(lambda x, y: x * y, term)
+                for term in itertools.product(*[arg.to_terms() for arg in args])
+            }),
+            Operator("**", arity=2, precedence=500, associativity='right', to_terms=power),
+        ]
+
+    def resolve(self, token: Token, max_prefix_arity) -> List[Operator]:
+        if token.token in self.operator_table:
+            return super().resolve(token, max_prefix_arity)
+
+        symbol = token.token
+
+        # Apply R-like transformations to operator
+        symbol = re.sub(r'[+\-]*\-[+\-]*', '-', symbol)  # Any sequence of '+' and '-' -> '-'
+        symbol = re.sub(r'[+]{2,}', '+', symbol)  # multiple sequential '+' -> '+'
+
+        if symbol in self.operator_table:
+            return [self._resolve(token, symbol, max_prefix_arity)]
+
+        return [
+            self._resolve(token, sym, max_prefix_arity if i == 0 else 0)
+            for i, sym in enumerate(symbol)
+        ]
diff --git a/formulaic/parser/types/__init__.py b/formulaic/parser/types/__init__.py
index 7dec558..6e3dddd 100644
--- a/formulaic/parser/types/__init__.py
+++ b/formulaic/parser/types/__init__.py
@@ -1,6 +1,7 @@
 from .ast_node import ASTNode
 from .factor import Factor
 from .operator import Operator
+from .operator_resolver import OperatorResolver
 from .term import Term
 from .token import Token
 
@@ -9,6 +10,7 @@ __all__ = [
     'ASTNode',
     'Factor',
     'Operator',
+    'OperatorResolver',
     'Term',
     'Token'
 ]
diff --git a/formulaic/parser/types/operator_resolver.py b/formulaic/parser/types/operator_resolver.py
new file mode 100644
index 0000000..cf351c5
--- /dev/null
+++ b/formulaic/parser/types/operator_resolver.py
@@ -0,0 +1,37 @@
+from collections import defaultdict
+from typing import List
+
+from ..utils import exc_for_token
+from .operator import Operator
+from .token import Token
+
+
+class OperatorResolver:
+
+    def __init__(self):
+        self.operator_table = defaultdict(list)
+        for operator in self.operators:
+            self.operator_table[operator.symbol].append(operator)
+        for symbol in self.operator_table:
+            self.operator_table[symbol] = sorted(self.operator_table[symbol], key=lambda op: op.precedence, reverse=True)
+
+    @property
+    def operators(self) -> List[Operator]:
+        return []  # pragma: no cover
+
+    def resolve(self, token: Token, max_prefix_arity) -> List[Operator]:
+        return [self._resolve(token, token.token, max_prefix_arity)]
+
+    def _resolve(self, token: Token, symbol: str, max_prefix_arity: int) -> Operator:
+        if symbol not in self.operator_table:
+            raise exc_for_token(token, f"Unknown operator '{symbol}'.")
+        candidates = [
+            candidate
+            for candidate in self.operator_table[symbol]
+            if max_prefix_arity == 0 and candidate.fixity is Operator.Fixity.PREFIX or max_prefix_arity > 0 and candidate.fixity is not Operator.Fixity.PREFIX
+        ]
+        if not candidates:
+            raise exc_for_token(token, f"Operator `{symbol}` is incorrectly used.")
+        if len(candidates) > 1:
+            raise exc_for_token(token, f"Ambiguous operator `{symbol}`. This is not usually a user error. Please report this!")
+        return candidates[0]
diff --git a/formulaic/parser/types/token.py b/formulaic/parser/types/token.py
index c6dea60..e669f39 100644
--- a/formulaic/parser/types/token.py
+++ b/formulaic/parser/types/token.py
@@ -19,7 +19,7 @@ class Token:
         self.kind = kind
         self.source = source
         self.source_start = source_start
-        self.source_end = source_end
+        self.source_end = source_end or source_start
 
     @property
     def kind(self):
@@ -77,5 +77,14 @@ class Token:
     def flatten(self, str_args=False):
         return str(self) if str_args else self
 
+    def get_source_context(self, colorize=False):
+        if not self.source or self.source_start is None or self.source_end is None:
+            return None
+        if colorize:
+            RED_BOLD = "\x1b[1;31m"
+            RESET = "\x1b[0m"
+            return f"{self.source[:self.source_start]}⧛{RED_BOLD}{self.source[self.source_start:self.source_end+1]}{RESET}⧚{self.source[self.source_end+1:]}"
+        return f"{self.source[:self.source_start]}⧛{self.source[self.source_start:self.source_end+1]}⧚{self.source[self.source_end+1:]}"
+
     def __repr__(self):
         return self.token
diff --git a/formulaic/parser/utils.py b/formulaic/parser/utils.py
new file mode 100644
index 0000000..2fe61e7
--- /dev/null
+++ b/formulaic/parser/utils.py
@@ -0,0 +1,44 @@
+from formulaic.errors import FormulaSyntaxError
+from .types.ast_node import ASTNode
+from .types.token import Token
+
+
+def exc_for_token(token, message, errcls=FormulaSyntaxError):
+    token = __get_token_for_ast(token)
+    token_context = token.get_source_context(colorize=True)
+    if token_context:
+        return errcls(f"{message}\n\n{token_context}")
+    return errcls(message)
+
+
+def exc_for_missing_operator(lhs, rhs, errcls=FormulaSyntaxError):
+    lhs_token, rhs_token, error_token = __get_tokens_for_gap(lhs, rhs)
+    raise exc_for_token(error_token, f"Missing operator between `{lhs_token.token}` and `{rhs_token.token}`.", errcls=errcls)
+
+
+def __get_token_for_ast(ast):
+    if isinstance(ast, Token):
+        return ast
+    lhs_token = ast
+    while isinstance(lhs_token, ASTNode):
+        lhs_token = lhs_token.args[0]
+    rhs_token = ast
+    while isinstance(rhs_token, ASTNode):
+        rhs_token = rhs_token.args[-1]
+    return Token(
+        token=lhs_token.source[lhs_token.source_start:rhs_token.source_end + 1] if lhs_token.source else '',
+        source=lhs_token.source, source_start=lhs_token.source_start, source_end=rhs_token.source_end
+    )
+
+
+def __get_tokens_for_gap(lhs, rhs):
+    lhs_token = lhs
+    while isinstance(lhs_token, ASTNode):
+        lhs_token = lhs_token.args[-1]
+    rhs_token = rhs or lhs
+    while isinstance(rhs_token, ASTNode):
+        rhs_token = rhs_token.args[0]
+    return lhs_token, rhs_token, Token(
+        lhs_token.source[lhs_token.source_start:rhs_token.source_end + 1] if lhs_token.source else '',
+        source=lhs_token.source, source_start=lhs_token.source_start, source_end=rhs_token.source_end
+    )
diff --git a/setup.py b/setup.py
index 9c158ea..b89fa44 100644
--- a/setup.py
+++ b/setup.py
@@ -47,7 +47,7 @@ setup(
     extras_require={
         'arrow': ['pyarrow'],
         'benchmarks': ['patsy', 'rpy2', 'uncertainties'],
-        'test': test_deps
+        'test': test_deps,
     },
 
 )


Test patch:
diff --git a/tests/parser/algos/test_infix_to_ast.py b/tests/parser/algos/test_infix_to_ast.py
index 35d3c28..da9e109 100644
--- a/tests/parser/algos/test_infix_to_ast.py
+++ b/tests/parser/algos/test_infix_to_ast.py
@@ -3,32 +3,21 @@ import pytest
 import functools
 import itertools
 
+from formulaic.errors import FormulaSyntaxError
+from formulaic.parser import DefaultOperatorResolver
 from formulaic.parser.algos.infix_to_ast import infix_to_ast
 from formulaic.parser.algos.tokenize import tokenize
 from formulaic.parser.types import Operator
 
 
-OPERATORS = [
-    Operator("~", arity=2, precedence=-100, associativity=None, to_terms=lambda lhs, rhs: (lhs.to_terms(), rhs.to_terms())),
-    Operator("~", arity=1, precedence=-100, associativity=None, fixity='prefix', to_terms=lambda lhs, rhs: (None, rhs.to_terms())),
-    Operator("+", arity=2, precedence=100, associativity='left', to_terms=lambda *args: set(itertools.chain(*[arg.to_terms() for arg in args]))),
-    Operator("-", arity=2, precedence=100, associativity='left', to_terms=lambda left, right: set(set(left.to_terms()).difference(right.to_terms()))),
-    Operator("+", arity=1, precedence=100, associativity='right', fixity='prefix'),
-    Operator("-", arity=1, precedence=100, associativity='right', fixity='prefix'),
-    Operator("*", arity=2, precedence=200, associativity='left', to_terms=lambda *args: (
-        {
-            functools.reduce(lambda x, y: x * y, term)
-            for term in itertools.product(*[arg.to_terms() for arg in args])
-        }
-        .union(itertools.chain(*[arg.to_terms() for arg in args]))
-    )),
-    Operator("/", arity=2, precedence=200, associativity='left'),
-    Operator(":", arity=2, precedence=300, associativity='left', to_terms=lambda *args: {
-        functools.reduce(lambda x, y: x * y, term)
-        for term in itertools.product(*[arg.to_terms() for arg in args])
-    }),
-    Operator("**", arity=2, precedence=500, associativity='right'),
-]
+class ExtendedOperatorResolver(DefaultOperatorResolver):
+
+    @property
+    def operators(self):
+        return super().operators + [
+            # Acts like '+' but as a postfix operator
+            Operator("@", arity=2, precedence=1000, associativity='right', fixity='postfix', to_terms=lambda *args: set(itertools.chain(*[arg.to_terms() for arg in args]))),
+        ]
 
 
 FORMULA_TO_AST_TESTS = {
@@ -43,6 +32,7 @@ FORMULA_TO_AST_TESTS = {
     # Simple addition
     '1 + 2': ['+', '1', '2'],
     'a + 1': ['+', 'a', '1'],
+    'a 1 @': ['@', 'a', '1'],
 
     # Parentheses
     '(a + 1)': ['+', 'a', '1'],
@@ -73,11 +63,25 @@ FORMULA_TO_AST_TESTS = {
     '-a:b': ['-', [':', 'a', 'b']],
 }
 
+FORMULA_ERRORS = {
+    'a b +': [FormulaSyntaxError, r"Operator `\+` has insuffient arguments and/or is misplaced."],
+    '( a + b': [FormulaSyntaxError, r"Could not find matching parenthesis."],
+    'a + b )': [FormulaSyntaxError, r"Could not find matching parenthesis."],
+    'a b': [FormulaSyntaxError, r"Missing operator between `a` and `b`."],
+    'y + y2 y3 ~ x + z': [FormulaSyntaxError, r"Missing operator between `y2` and `y3`."],
+}
+
 
 @pytest.mark.parametrize("formula,flattened", FORMULA_TO_AST_TESTS.items())
 def test_formula_to_ast(formula, flattened):
-    ast = infix_to_ast(tokenize(formula), OPERATORS)
+    ast = infix_to_ast(tokenize(formula), ExtendedOperatorResolver())
     if flattened is None:
         assert ast is flattened
     else:
         assert ast.flatten(str_args=True) == flattened
+
+
+@pytest.mark.parametrize("formula,exception_info", FORMULA_ERRORS.items())
+def test_tokenize_exceptions(formula, exception_info):
+    with pytest.raises(exception_info[0], match=exception_info[1]):
+        list(infix_to_ast(tokenize(formula), ExtendedOperatorResolver()))
diff --git a/tests/parser/algos/test_tokenize.py b/tests/parser/algos/test_tokenize.py
index 2a45990..0592a2b 100644
--- a/tests/parser/algos/test_tokenize.py
+++ b/tests/parser/algos/test_tokenize.py
@@ -1,7 +1,7 @@
 import pytest
 
 from formulaic.parser.algos.tokenize import tokenize
-from formulaic.errors import FormulaParsingError
+from formulaic.errors import FormulaSyntaxError
 
 
 TOKEN_TESTS = {
@@ -20,11 +20,13 @@ TOKEN_TESTS = {
     '"abc" + "def"': ['value:"abc"', 'operator:+', 'value:"def"'],
     '`a|b * 2:a{}`': ["name:a|b * 2:a{}"],
     '{`a|b` @ `b2:1`}': ["python:`a|b` @ `b2:1`"],
+    'I(`a|b`)': ["python:I(`a|b`)"],
     'a + `a+b` + {a / b}': ["name:a", 'operator:+', "name:a+b", 'operator:+', "python:a / b"],
 }
 
 TOKEN_ERRORS = {
-    'a"hello"': [FormulaParsingError, "Unexpected character '\"' following token 'a'."],
+    'a"hello"': [FormulaSyntaxError, "Unexpected character '\"' following token `a`."],
+    '`a': [FormulaSyntaxError, "Formula ended before quote context was closed. Expected: `"],
 }
 
 
diff --git a/tests/parser/test_parser.py b/tests/parser/test_parser.py
index 7b64ae9..54cccef 100644
--- a/tests/parser/test_parser.py
+++ b/tests/parser/test_parser.py
@@ -1,7 +1,8 @@
 import pytest
 
-from formulaic.errors import FormulaParsingError
-from formulaic.parser import FormulaParser
+from formulaic.errors import FormulaParsingError, FormulaSyntaxError
+from formulaic.parser import FormulaParser, DefaultOperatorResolver
+from formulaic.parser.types import Token
 
 
 FORMULA_TO_TOKENS = {
@@ -92,3 +93,35 @@ class TestFormulaParser:
     def test_invalid_use_of_zero(self):
         with pytest.raises(FormulaParsingError):
             PARSER.get_terms('a * 0')
+
+    def test_invalid_power(self):
+        with pytest.raises(FormulaSyntaxError, match=r'The right-hand argument of `\*\*` must be a positive integer.'):
+            assert PARSER.get_terms('a**b')
+
+        with pytest.raises(FormulaSyntaxError, match=r'The right-hand argument of `\*\*` must be a positive integer.'):
+            assert PARSER.get_terms('a**(b+c)')
+
+
+class TestDefaultOperatorResolver:
+
+    @pytest.fixture
+    def resolver(self):
+        return DefaultOperatorResolver()
+
+    def test_resolve(self, resolver):
+        assert len(resolver.resolve(Token('+++++'), 1)) == 1
+        assert resolver.resolve(Token('+++++'), 1)[0].symbol == '+'
+        assert resolver.resolve(Token('+++++'), 1)[0].arity == 2
+
+        assert len(resolver.resolve(Token('+++-+'), 1)) == 1
+        assert resolver.resolve(Token('+++-+'), 1)[0].symbol == '-'
+        assert resolver.resolve(Token('+++-+'), 1)[0].arity == 2
+
+        assert len(resolver.resolve(Token('*+++-+'), 1)) == 2
+        assert resolver.resolve(Token('*+++-+'), 1)[0].symbol == '*'
+        assert resolver.resolve(Token('*+++-+'), 1)[0].arity == 2
+        assert resolver.resolve(Token('*+++-+'), 1)[1].symbol == '-'
+        assert resolver.resolve(Token('*+++-+'), 1)[1].arity == 1
+
+        with pytest.raises(FormulaSyntaxError, match="Operator `/` is incorrectly used."):
+            resolver.resolve(Token('*/'), 2)
diff --git a/tests/parser/types/test_operator_resolver.py b/tests/parser/types/test_operator_resolver.py
new file mode 100644
index 0000000..2209856
--- /dev/null
+++ b/tests/parser/types/test_operator_resolver.py
@@ -0,0 +1,43 @@
+import pytest
+
+from formulaic.errors import FormulaParsingError, FormulaSyntaxError
+from formulaic.parser.types import Operator, OperatorResolver, Token
+
+OPERATOR_PLUS = Operator("+", arity=2, precedence=100, fixity='infix')
+OPERATOR_UNARY_MINUS = Operator("-", arity=1, precedence=100, fixity='prefix')
+OPERATOR_COLON = Operator(":", arity=1, precedence=100, fixity='postfix')
+OPERATOR_COLON_2 = Operator(":", arity=2, precedence=100, fixity='infix')
+
+class DummyOperatorResolver(OperatorResolver):
+
+    @property
+    def operators(self):
+        return [
+            OPERATOR_PLUS,
+            OPERATOR_UNARY_MINUS,
+            OPERATOR_COLON,
+            OPERATOR_COLON_2,
+        ]
+
+
+class TestOperatorResolver:
+
+    @pytest.fixture
+    def resolver(self):
+        return DummyOperatorResolver()
+
+    def test_resolve(self, resolver):
+        assert resolver.resolve(Token('+'), 1)[0] is OPERATOR_PLUS
+        assert resolver.resolve(Token('-'), 0)[0] is OPERATOR_UNARY_MINUS
+
+        with pytest.raises(FormulaSyntaxError):
+            resolver.resolve(Token('@'), 0)
+
+        with pytest.raises(FormulaSyntaxError):
+            resolver.resolve(Token('+'), 0)
+
+        with pytest.raises(FormulaSyntaxError):
+            resolver.resolve(Token('-'), 1)
+
+        with pytest.raises(FormulaParsingError, match="Ambiguous operator `:`"):
+            resolver.resolve(Token(':'), 1)
diff --git a/tests/parser/types/test_token.py b/tests/parser/types/test_token.py
index df49e86..c53b712 100644
--- a/tests/parser/types/test_token.py
+++ b/tests/parser/types/test_token.py
@@ -10,7 +10,7 @@ class TestToken:
 
     @pytest.fixture
     def token_b(self):
-        return Token('log(x)', kind='python')
+        return Token('log(x)', kind='python', source='y ~ log(x)', source_start=4, source_end=9)
 
     @pytest.fixture
     def token_c(self):
@@ -66,3 +66,10 @@ class TestToken:
     def test_flatten(self, token_a):
         assert token_a.flatten(str_args=False) is token_a
         assert token_a.flatten(str_args=True) is 'a'
+
+    def test_get_source_context(self, token_a, token_b, token_c):
+        assert token_a.get_source_context() is None
+        assert token_b.get_source_context() == 'y ~ ⧛log(x)⧚'
+        assert token_c.get_source_context() is None
+
+        assert token_b.get_source_context(colorize=True) == 'y ~ ⧛\x1b[1;31mlog(x)\x1b[0m⧚'