Browse Source

Reimplement parsing using arpeggio

Ryan C. Thompson 6 years ago
parent
commit
ac4a0bdd99
2 changed files with 651 additions and 607 deletions
  1. 2 0
      mypy.ini
  2. 649 607
      roll.py

+ 2 - 0
mypy.ini

@@ -5,3 +5,5 @@ ignore_missing_imports = True
 ignore_missing_imports = True
 [mypy-colors]
 ignore_missing_imports = True
+[mypy-arpeggio]
+ignore_missing_imports = True

+ 649 - 607
roll.py

@@ -9,9 +9,10 @@ import operator
 import traceback
 from numbers import Number
 from random import SystemRandom
-from pyparsing import ParserElement, Token, Regex, oneOf, Optional, Group, Combine, Literal, CaselessLiteral, ZeroOrMore, StringStart, StringEnd, opAssoc, infixNotation, ParseException, Empty, pyparsing_common, ParseResults, White, Suppress
+from copy import copy
 
-from typing import Union, List, Any, Tuple, Sequence, Dict, Callable, Set, TextIO
+from arpeggio import ParserPython, RegExMatch, Optional, ZeroOrMore, OneOrMore, OrderedChoice, Sequence, Combine, Not, EOF, PTNodeVisitor, visit_parse_tree, ParseTreeNode, SemanticActionResults
+from typing import Union, List, Any, Tuple, Dict, Callable, Set, TextIO
 from typing import Optional as OptionalType
 
 try:
@@ -41,152 +42,265 @@ for handler in logger.handlers:
 sysrand = SystemRandom()
 randint = sysrand.randint
 
-def int_limit_converter(x: Any) -> OptionalType[int]:
-    if x is None:
-        return None
-    else:
-        return int(x)
+# Implementing the syntax described here: https://www.critdice.com/roll-advanced-dice
+# https://stackoverflow.com/a/23956778/125921
 
-@attr.s
-class IntegerValidator(object):
-    min_val: OptionalType[int] = attr.ib(default = None, converter = int_limit_converter)
-    max_val: OptionalType[int] = attr.ib(default = None, converter = int_limit_converter)
-    handle_float: str = attr.ib(default = 'exception')
-    @handle_float.validator
-    def validate_handle_float(self, attribute, value):
-        assert value in ('exception', 'truncate', 'round')
-    value_name: str = attr.ib(default = "value")
-
-    def __call__(self, value: Any) -> int:
-        xf: float
-        x: int
-        try:
-            xf = float(value)
-        except ValueError:
-            raise ValueError('{} {} does not look like a number'.format(self.value_name, value))
-        if not xf.is_integer():
-            if self.handle_float == 'exception':
-                raise ValueError('{} {} is not an integer'.format(self.value_name, value))
-            elif self.handle_float == 'truncate':
-                x = int(xf)
-            else:
-                x = round(xf)
+# Whitespace parsing
+def Whitespace(): return RegExMatch(r'\s+')
+def OpWS(): return Optional(Whitespace)
+
+# Number parsing
+def Digits(): return RegExMatch('[0-9]+')
+def NonzeroDigits():
+    '''Digits with at least one nonzero number.'''
+    return RegExMatch('0*[1-9][0-9]*')
+def Sign(): return ['+', '-']
+def Integer(): return Optional(Sign), Digits
+def PositiveInteger(): return Optional('+'), Digits
+def FloatingPoint():
+    return (
+        Optional(Sign),
+        [
+            # e.g. '1.', '1.0'
+            (Digits, '.', Optional(Digits)),
+            # e.g. '.1'
+            ('.', Digits),
+        ]
+    )
+def Scientific():
+    return ([FloatingPoint, Integer], RegExMatch('[eE]'), Integer)
+def Number(): return Combine([Scientific, FloatingPoint, Integer])
+
+def ReservedWord():
+    '''Matches identifiers that aren't allowed as variable names.'''
+    command_word_parsers = []
+    for cmd_type in Command():
+        cmd_parser = cmd_type()
+        if isinstance(cmd_parser, tuple):
+            command_word_parsers.append(cmd_parser[0])
         else:
-            x = int(xf)
-        if self.min_val is not None and x < self.min_val:
-                raise ValueError('{} {} is too small; must be at least {}'.format(self.value_name, value, self.min_val))
-        if self.max_val is not None and x > self.max_val:
-                raise ValueError('{} {} is too large; must be at most {}'.format(self.value_name, value, self.max_val))
-        return x
-
-die_face_num_validator = IntegerValidator(
-    min_val = 2, handle_float = 'exception',
-    value_name = 'die type',
+            command_word_parsers.append(cmd_parser)
+    return([
+        # Starts with a roll expression
+        RollExpr,
+        # Matches a command word exactly
+        (command_word_parsers, [RegExMatch('[^A-Za-z0-9_]'), EOF]),
+    ])
+
+# Valid variable name parser (should disallow names like 'help', 'quit', or 'd4r')
+def Identifier(): return (
+        Not(ReservedWord),
+        RegExMatch(r'[A-Za-z_][A-Za-z0-9_]*')
 )
 
-DieFaceType = Union[int, str]
-def is_fate_face(x: DieFaceType) -> bool:
-    if isinstance(x, int):
-        return False
-    else:
-        x = str(x).upper()
-        return x in ('F', 'F.1', 'F.2')
+def MyNum(): return (
+        Not('0'),
+        RegExMatch('[0-9]+'),
+)
 
-def normalize_die_type(x: DieFaceType) -> DieFaceType:
-    if is_fate_face(x):
-        return str(x).upper()
-    elif x == '%':
-        return 100
-    else:
-        return die_face_num_validator(x)
+# Roll expression parsing
+def PercentileFace(): return '%'
+def DieFace(): return [NonzeroDigits, PercentileFace, RegExMatch(r'F(\.[12])?')]
+def BasicRollExpr():
+    return (
+        Optional(NonzeroDigits),
+        RegExMatch('[dD]'),
+        DieFace,
+    )
+def DropSpec(): return 'K k X x -H -L'.split(' '), Optional(NonzeroDigits)
+def CompareOp(): return '<= < >= > ≤ ≥ ='.split(' ')
+def Comparison(): return CompareOp, Integer
+def RerollType(): return Combine(['r', 'R', ('!', Optional('!'), Optional('p'))])
+def RerollSpec():
+    return  (
+        RerollType,
+        Optional(
+            Optional(CompareOp),
+            Integer,
+        ),
+    )
+def CountSpec():
+    return (
+        Comparison,
+        Optional('f', Comparison),
+    )
+
+def RollExpr():
+    return (
+        BasicRollExpr,
+        Optional([DropSpec, RerollSpec]),
+        Optional(CountSpec),
+    )
 
-dice_count_validator = IntegerValidator(
-    min_val = 1, handle_float = 'exception',
-    value_name = 'dice count'
+# Arithmetic expression parsing
+def PrimaryTerm(): return [RollExpr, Number, Identifier]
+def TermOrGroup(): return [PrimaryTerm, ParenExpr]
+def Exponent(): return ['**', '^'], OpWS, TermOrGroup
+def ExponentExpr(): return TermOrGroup, ZeroOrMore(OpWS, Exponent)
+def Mul(): return ['*', '×'], OpWS, ExponentExpr
+def Div(): return ['/', '÷'], OpWS, ExponentExpr
+def ProductExpr(): return ExponentExpr, ZeroOrMore(OpWS, [Mul, Div])
+def Add(): return '+', OpWS, ProductExpr
+def Sub(): return '-', OpWS, ProductExpr
+def SumExpr(): return ProductExpr, ZeroOrMore(OpWS, [Add, Sub])
+def ParenExpr(): return Optional(Sign), '(', OpWS, SumExpr, OpWS, ')'
+def Expression():
+    # Wrapped in a tuple to force a separate entry in the parse tree
+    return (SumExpr,)
+
+# For parsing vars/expressions without evaluating them. The Combine()
+# hides the child nodes from a visitor.
+def UnevaluatedExpression(): return Combine(Expression)
+def UnevaluatedVariable(): return Combine(Identifier)
+
+# Variable assignment
+def VarAssignment(): return (
+        UnevaluatedVariable,
+        OpWS, '=', OpWS,
+        UnevaluatedExpression
 )
 
-# Just a named function wrapper for dice_count_validator
-def normalize_dice_count(x: Any) -> int:
-    return dice_count_validator(x)
+# Commands
+def DeleteCommand(): return (
+        Combine(['delete', 'del', 'd']),
+        Whitespace,
+        UnevaluatedVariable,
+)
+def HelpCommand(): return Combine(['help', 'h', '?'])
+def QuitCommand(): return Combine(['quit', 'exit', 'q'])
+def ListVarsCommand(): return Combine(['variables', 'vars', 'v'])
+def Command(): return [ ListVarsCommand, DeleteCommand, HelpCommand, QuitCommand, ]
 
-def ImplicitToken(x) -> ParserElement:
-    '''Like pyparsing.Empty, but yields one or more tokens instead of nothing.'''
-    return Empty().setParseAction(lambda toks: x)
+def InputParser(): return Optional([Command, VarAssignment, Expression, Whitespace])
 
-# TODO: Look at http://infohost.nmt.edu/tcc/help/pubs/pyparsing/web/classes.html#class-ParserElement
+def FullParserPython(language_def, *args, **kwargs):
+    '''Like ParserPython, but auto-adds EOF to the end of the parser.'''
+    def TempFullParser(): return (language_def, EOF)
+    return ParserPython(TempFullParser, *args, **kwargs)
 
-# Implementing the syntax described here: https://www.critdice.com/roll-advanced-dice
-# https://stackoverflow.com/a/23956778/125921
+expr_parser = FullParserPython(Expression, skipws = False, memoization = True)
+input_parser = FullParserPython(InputParser, skipws = False, memoization = True)
 
-# https://stackoverflow.com/a/46583691/125921
+def test_parse(rule, text):
+    if isinstance(text, str):
+        return FullParserPython(rule, skipws=False, memoization = True).parse(text)
+    else:
+        return [ test_parse(rule, x) for x in text ]
+
+test_parse(Number, '+6.0223e23')
+test_parse(RollExpr, [
+    '4d4',
+    '2d20K',
+    '8d6x1',
+    '8d4!p<=1',
+    '8d4r4',
+    '8d6r1>3f<3',
+])
+test_parse(Expression, [
+    'x+1',
+    '4d4+4',
+    '2*2',
+    '(2*2)',
+    '2d20K + d6 + (2 * 2 ^ 2)',
+])
+test_parse(VarAssignment, [
+    'x= 5',
+    'int = d20 + 7',
+])
+test_parse(InputParser, [
+    '4d4',
+    '2d20K',
+    '8d6x1',
+    '8d4!p<=1',
+    '8d4r4',
+    '8d6r1>3f<3',
+    'x+1',
+    '4d4+4',
+    '2*2',
+    '(2*2)',
+    '2d20K + d6 + (2 * 2 ^ 2)',
+    'x= 5',
+    'int = d20 + 7',
+    'del x',
+    'delete x',
+    'help',
+    'quit',
+    'v',
+])
+
+def eval_infix(terms: List[float],
+               operators: List[Callable[[float,float],float]],
+               associativity: str = 'l') -> float:
+    '''Evaluate an infix expression with N terms and N-1 operators.'''
+    assert associativity in ['l', 'r']
+    assert len(terms) == len(operators) + 1, 'Need one more term than operator'
+    if len(terms) == 1:
+        return terms[0]
+    elif associativity == 'l':
+        value = terms[0]
+        for op, term in zip(operators, terms[1:]):
+            value = op(value, term)
+        return value
+    elif associativity == 'r':
+        value = terms[-1]
+        for op, term in zip(reversed(operators), reversed(terms[:-1])):
+            value = op(term, value)
+        return value
+    else:
+        raise ValueError(f'Invalid associativity: {associativity!r}')
 
-var_name: ParserElement = pyparsing_common.identifier.copy().setResultsName('varname')
-real_num: ParserElement = pyparsing_common.fnumber.copy()
-positive_int: ParserElement = pyparsing_common.integer.copy().setParseAction(lambda toks: [ IntegerValidator(min_val=1)(toks[0]) ])
+class UndefinedVariableError(KeyError):
+    pass
 
-drop_type: ParserElement = oneOf('K k X x -H -L')
-drop_spec: ParserElement = Group(
-    drop_type.setResultsName('type') +
-    (positive_int | ImplicitToken(1)).setResultsName('count')
-).setResultsName('drop')
+def print_vars(env: Dict[str,str]) -> None:
+    if len(env):
+        print('Currently defined variables:')
+        for k in sorted(env.keys()):
+            print('{} = {!r}'.format(k, env[k]))
+    else:
+        print('No variables are currently defined.')
 
-pos_int_implicit_one: ParserElement = (positive_int | ImplicitToken(1))
+def print_interactive_help() -> None:
+    print('\n' + '''
 
-comparator_type: ParserElement = oneOf('<= < >= > ≤ ≥ =')
+To make a roll, type in the roll in dice notation, e.g. '4d4 + 4'.
+Nearly all dice notation forms listed in the following references should be supported:
 
-reroll_type: ParserElement = Combine(oneOf('R r') ^ ( oneOf('! !!') + Optional('p')))
-reroll_spec: ParserElement = Group(
-    reroll_type.setResultsName('type') +
-    Optional(
-        (comparator_type | ImplicitToken('=')).setResultsName('operator') + \
-        positive_int.setResultsName('value')
-    )
-).setResultsName('reroll')
-
-count_spec: ParserElement = Group(
-    Group(
-        comparator_type.setResultsName('operator') + \
-        positive_int.setResultsName('value')
-    ).setResultsName('success_condition') +
-    Optional(
-        Literal('f') +
-        Group(
-            comparator_type.setResultsName('operator') + \
-            positive_int.setResultsName('value')
-        ).setResultsName('failure_condition')
-    )
-).setResultsName('count_successes')
-
-roll_spec: ParserElement = Group(
-    (positive_int | ImplicitToken(1)).setResultsName('dice_count') +
-    CaselessLiteral('d') +
-    (positive_int | oneOf('% F F.1 F.2')).setResultsName('die_type') +
-    Optional(reroll_spec ^ drop_spec) +
-    Optional(count_spec)
-).setResultsName('roll')
-
-expr_parser: ParserElement = infixNotation(
-    baseExpr=(roll_spec ^ positive_int ^ real_num ^ var_name),
-    opList=[
-        (oneOf('** ^').setResultsName('operator', True), 2, opAssoc.RIGHT),
-        (oneOf('* / × ÷').setResultsName('operator', True), 2, opAssoc.LEFT),
-        (oneOf('+ -').setResultsName('operator', True), 2, opAssoc.LEFT),
-    ]
-).setResultsName('expr')
-
-assignment_parser: ParserElement = var_name + Literal('=').setResultsName('assignment') + expr_parser
-
-def roll_die(sides: DieFaceType = 6) -> int:
+- http://rpg.greenimp.co.uk/dice-roller/
+- https://www.critdice.com/roll-advanced-dice
+
+Expressions can include numeric constants, addition, subtraction,
+multiplication, division, and exponentiation.
+
+To assign a variable, use 'VAR = VALUE'. For example 'health_potion =
+2d4+2'. Subsequent roll expressions (and other variables) can refer to
+this variable, whose value will be substituted in to the expression.
+
+If a variable's value includes any dice rolls, those dice will be
+rolled (and produce a different value) every time the variable is
+used.
+
+Special commands:
+
+- To show the values of all currently assigned variables, type 'vars'.
+- To delete a previously defined variable, type 'del VAR'.
+- To show this help text, type 'help'.
+- To quit, type 'quit'.
+
+    '''.strip() + '\n', file=sys.stdout)
+
+DieFaceType = Union[int, str]
+def roll_die(face: DieFaceType = 6) -> int:
     '''Roll a single die.
 
-Supports any valid integer number of sides as well as 'F' for a fate
-die, which can return -1, 0, or 1 with equal probability.
+Supports any valid integer number of sides as well as 'F', 'F.1', and
+'F.2' for a Face die, which can return -1, 0, or 1.
 
     '''
-    if sides in ('F', 'F.2'):
+    if face in ('F', 'F.2'):
         # Fate die = 1d3-2
         return roll_die(3) - 2
-    elif sides == 'F.1':
+    elif face == 'F.1':
         d6 = roll_die(6)
         if d6 == 1:
             return -1
@@ -195,15 +309,41 @@ die, which can return -1, 0, or 1 with equal probability.
         else:
             return 0
     else:
-        return randint(1, int(sides))
+        face = int(face)
+        if face < 2:
+            raise ValueError(f"Can't roll a {face}-sided die")
+        return randint(1, face)
+
+def roll_die_with_rerolls(face: int, reroll_condition: Callable, reroll_limit = None) -> List[int]:
+    '''Roll a single die, and maybe reroll it several times.
+
+    After each roll, 'reroll_condition' is called on the result, and
+    if it returns True, the die is rolled again. All rolls are
+    collected in a list, and the list is returned as soon as the
+    condition returns False.
+
+    If reroll_limit is provided, it limits the maximum number of
+    rerolls. Note that the total number of rolls can be one more than
+    the reroll limit, since the first roll is not considered a reroll.
+
+    '''
+    all_rolls = [roll_die(face)]
+    while reroll_condition(all_rolls[-1]):
+        if reroll_limit is not None and len(all_rolls) > reroll_limit:
+            break
+        all_rolls.append(roll_die(face))
+    return all_rolls
 
 class DieRolled(int):
-    '''Subclass of int that allows a string suffix.
+    '''Subclass of int that allows an alternate string representation.
 
     This is meant for recording the result of rolling a die. The
-    suffix is purely cosmetic, for the purposes of string conversion.
-    It can be used to indicate a die roll that has been re-rolled or
-    exploded, or to indicate a critical hit/miss.
+    formatter argument should include '{}' anywhere that the integer
+    value should be substituted into the string representation.
+    (However, it can also override the string representation entirely
+    by not including '{}'.) The string representation has no effect on
+    the numeric value. It can be used to indicate a die roll that has
+    been re-rolled or exploded, or to indicate a critical hit/miss.
 
     '''
     formatter: str
@@ -219,21 +359,9 @@ class DieRolled(int):
 
     def __repr__(self) -> str:
         if self.formatter != '{}':
-            return 'DieRolled(value={value!r}, formatter={formatter!r})'.format(
-                value=int(self),
-                formatter=self.formatter,
-            )
+            return f'DieRolled(value={int(self)!r}, formatter={self.formatter!r})'
         else:
-            return 'DieRolled({value!r})'.format(value=int(self))
-
-def normalize_dice_roll_list(value: List[Any]) -> List[int]:
-    result = []
-    for x in value:
-        if isinstance(x, int):
-            result.append(x)
-        else:
-            result.append(int(x))
-    return result
+            return f'DieRolled({int(self)!r})'
 
 def format_dice_roll_list(rolls: List[int], always_list: bool = False) -> str:
     if len(rolls) == 0:
@@ -249,19 +377,25 @@ def int_or_none(x: OptionalType[Any]) -> OptionalType[int]:
     else:
         return int(x)
 
+def str_or_none(x: OptionalType[Any]) -> OptionalType[str]:
+    if x is None:
+        return None
+    else:
+        return str(x)
+
 @attr.s
 class DiceRolled(object):
     '''Class representing the result of rolling one or more similar dice.'''
-    dice_results: List[int] = attr.ib(converter = normalize_dice_roll_list)
+    dice_results: List[int] = attr.ib()
     @dice_results.validator
     def validate_dice_results(self, attribute, value):
         if len(value) == 0:
             raise ValueError('Need at least one non-dropped roll')
-    dropped_results: List[int] = attr.ib(
-        default = attr.Factory(list),
-        converter = normalize_dice_roll_list)
-    roll_desc: str = attr.ib(default = '', converter = str)
-    success_count: OptionalType[int] = attr.ib(default = None, converter = int_or_none)
+    dropped_results: OptionalType[List[int]] = attr.ib(default = None)
+    roll_text: OptionalType[str] = attr.ib(
+        default = None, converter = str_or_none)
+    success_count: OptionalType[int] = attr.ib(
+        default = None, converter = int_or_none)
 
     def total(self) -> int:
         if self.success_count is not None:
@@ -270,8 +404,9 @@ class DiceRolled(object):
             return sum(self.dice_results)
 
     def __str__(self) -> str:
-        if self.roll_desc:
-            prefix = '{roll} rolled'.format(roll=color(self.roll_desc, EXPR_COLOR))
+        results = format_dice_roll_list(self.dice_results)
+        if self.roll_text:
+            prefix = '{text} rolled'.format(text=color(self.roll_text, EXPR_COLOR))
         else:
             prefix = 'Rolled'
         if self.dropped_results:
@@ -284,12 +419,7 @@ class DiceRolled(object):
             tot = ', Total: ' + color(str(self.total()), DETAIL_COLOR)
         else:
             tot = ''
-        return '{prefix}: {results}{drop}{tot}'.format(
-            prefix=prefix,
-            results=format_dice_roll_list(self.dice_results),
-            drop=drop,
-            tot=tot,
-        )
+        return f'{prefix}: {results}{drop}{tot}'
 
     def __int__(self) -> int:
         return self.total()
@@ -297,11 +427,15 @@ class DiceRolled(object):
     def __float__(self) -> float:
         return float(self.total())
 
-def validate_by_parser(parser):
-    '''Return a validator that validates anything parser can parse.'''
-    def private_validator(instance, attribute, value):
-        parser.parseString(str(value), True)
-    return private_validator
+cmp_dict = {
+    '<=': operator.le,
+    '<': operator.lt,
+    '>=': operator.ge,
+    '>': operator.gt,
+    '≤': operator.le,
+    '≥': operator.ge,
+    '=': operator.eq,
+}
 
 @attr.s
 class Comparator(object):
@@ -314,15 +448,17 @@ class Comparator(object):
         '≥': operator.ge,
         '=': operator.eq,
     }
-    operator: str = attr.ib(converter = str,
-                            validator = validate_by_parser(comparator_type))
-    value: int = attr.ib(converter = int,
-                         validator = validate_by_parser(positive_int))
+    operator: str = attr.ib(converter = str)
+    @operator.validator
+    def validate_operator(self, attribute, value):
+        if not value in self.cmp_dict:
+            raise ValueError(f'Unknown comparison operator: {value!r}')
+    value: int = attr.ib(converter = int)
 
     def __str__(self) -> str:
         return '{op}{val}'.format(op=self.operator, val=self.value)
 
-    def compare(self, x) -> bool:
+    def compare(self, x: float) -> bool:
         '''Return True if x satisfies the comparator.
 
         In other words, x is placed on the left-hand side of the
@@ -332,222 +468,115 @@ class Comparator(object):
         '''
         return self.cmp_dict[self.operator](x, self.value)
 
-@attr.s
-class RerollSpec(object):
-    # Yes, it has to be called type
-    type: str = attr.ib(converter = str, validator=validate_by_parser(reroll_type))
-    operator: OptionalType[str] = attr.ib(default = None)
-    value: OptionalType[int] = attr.ib(default = None)
-
-    def __attrs_post_init__(self):
-        if (self.operator is None) != (self.value is None):
-            raise ValueError('Operator and value must be provided together')
+    def __call__(self, x: float) -> bool:
+        '''Calls Comparator.compare() on x.
 
-    def __str__(self) -> str:
-        result = self.type
-        if self.operator is not None:
-            result += self.operator + str(self.value)
-        return result
+        This allows the Comparator to be used as a callable.'''
+        return self.compare(x)
 
-    def roll_die(self, sides: DieFaceType) -> List[int]:
-        '''Roll a single die, following specified re-rolling rules.
+def roll_dice(roll_desc: Dict) -> DiceRolled:
+    '''Roll dice based on a roll description.
 
-        Returns a list of rolls, since some types of re-rolling
-        collect the result of multiple die rolls.
-
-        '''
-        if is_fate_face(sides):
-            raise ValueError("Re-rolling/exploding is incompatible with Fate dice")
-        sides = int(sides)
-
-        cmpr: Comparator
-        if self.value is None:
-            if self.type in ('R', 'r'):
-                cmpr = Comparator('=', 1)
-            else:
-                cmpr = Comparator('=', sides)
-        else:
-            cmpr = Comparator(self.operator, self.value)
-
-        if self.type == 'r':
-            # Single reroll
-            roll = roll_die(sides)
-            if cmpr.compare(roll):
-                roll = DieRolled(roll_die(sides), '{}' + self.type)
-            return [ roll ]
-        elif self.type == 'R':
-            # Indefinite reroll
-            roll = roll_die(sides)
-            while cmpr.compare(roll):
-                roll = DieRolled(roll_die(sides), '{}' + self.type)
-            return [ roll ]
-        elif self.type in ['!', '!!', '!p', '!!p']:
-            # Explode/penetrate/compound
-            all_rolls: List[int] = [ roll_die(sides) ]
-            while cmpr.compare(all_rolls[-1]):
-                all_rolls.append(roll_die(sides))
-            # If we never re-rolled, no need to do anything special
-            if len(all_rolls) == 1:
-                return all_rolls
-            # For penetration, subtract 1 from all rolls except the first
-            if self.type.endswith('p'):
-                for i in range(1, len(all_rolls)):
-                    all_rolls[i] -= 1
-            # For compounding, return the sum
-            if self.type.startswith('!!'):
-                total = sum(all_rolls)
-                return [ DieRolled(total, '{}' + self.type) ]
-            else:
-                for i in range(0, len(all_rolls)-1):
-                    all_rolls[i] = DieRolled(all_rolls[i], '{}' + self.type)
-                return all_rolls
-        else:
-            raise Exception('Unknown reroll type: {}'.format(self.type))
-
-@attr.s
-class DropSpec(object):
-    # Yes, it has to be called type
-    type: str = attr.ib(converter = str, validator=validate_by_parser(drop_type))
-    count: int = attr.ib(default = 1, converter = int, validator=validate_by_parser(positive_int))
-
-    def __str__(self) -> str:
-        if self.count > 1:
-            return self.type + str(self.count)
-        else:
-            return self.type
+    See InputHandler.visit_RollExpr(), which generates roll
+    descriptions. This function assumes the roll description is
+    already validated.
 
-    def drop_rolls(self, rolls: List[int]) -> Tuple[List[int], List[int]]:
-        '''Drop the appripriate rolls from a list of rolls.
+    Returns a tuple of two lists. The first list is the kept rolls,
+    and the second list is the dropped rolls.
 
-        Returns a 2-tuple of roll lists. The first list is the kept
-        rolls, and the second list is the dropped rolls.
-
-        The order of the rolls is not preserved. (TODO FIX THIS)
-
-        '''
-        if not isinstance(rolls, list):
-            rolls = list(rolls)
-        keeping = self.type in ('K', 'k')
-        if keeping:
-            num_to_keep = self.count
-        else:
-            num_to_keep = len(rolls) - self.count
-        if num_to_keep == 0:
-            raise ValueError('Not enough rolls: would drop all rolls')
-        elif num_to_keep == len(rolls):
-            raise ValueError('Keeping too many rolls: would not drop any rolls')
-        rolls.sort()
-        if self.type in ('K', 'X', '-H'):
-            rolls.reverse()
-        (head, tail) = rolls[:self.count], rolls[self.count:]
-        if keeping:
-            (kept, dropped) = (head, tail)
-        else:
-            (kept, dropped) = (tail, head)
-        return (kept, dropped)
-
-@attr.s
-class DiceRoller(object):
-    die_type: DieFaceType = attr.ib(converter = normalize_die_type)
-    dice_count: int = attr.ib(default = 1, converter = normalize_dice_count)
-    reroll_spec: OptionalType[RerollSpec] = attr.ib(default = None)
-    @reroll_spec.validator
-    def validate_reroll_spec(self, attribute, value):
-        if value is not None:
-            assert isinstance(value, RerollSpec)
-    drop_spec: OptionalType[DropSpec] = attr.ib(default = None)
-    @drop_spec.validator
-    def validate_drop_spec(self, attribute, value):
-        if value is not None:
-            assert isinstance(value, DropSpec)
-    success_comparator: OptionalType[Comparator] = attr.ib(default = None)
-    failure_comparator: OptionalType[Comparator] = attr.ib(default = None)
-    @success_comparator.validator
-    @failure_comparator.validator
-    def validate_comparator(self, attribute, value):
-        if value is not None:
-            assert isinstance(value, Comparator)
-
-    def __attrs_post_init__(self):
-        if self.reroll_spec is not None and self.drop_spec is not None:
-            raise ValueError('Reroll and drop specs are mutually exclusive')
-        if self.success_comparator is None and self.failure_comparator is not None:
-            raise ValueError('Cannot use a failure condition without a success condition')
-
-    def __str__(self) -> str:
-        return '{count}d{type}{reroll}{drop}{success}{fail}'.format(
-            count = self.dice_count if self.dice_count > 1 else '',
-            type = self.die_type,
-            reroll = self.reroll_spec or '',
-            drop = self.drop_spec or '',
-            success = self.success_comparator or '',
-            fail = ('f' + str(self.failure_comparator)) if self.failure_comparator else '',
+    '''
+    die_face: DieFaceType = roll_desc['die_face']
+    dice_count: int = roll_desc['dice_count']
+    kept_rolls: List[int] = []
+    dropped_rolls: OptionalType[List[int]] = None
+    success_count: Optional[int] = None
+    if 'reroll_type' in roll_desc:
+        die_face = int(die_face) # No fate dice
+        reroll_type: str = roll_desc['reroll_type']
+        reroll_limit = 1 if reroll_type == 'r' else None
+        reroll_desc: Dict = roll_desc['reroll_desc']
+        reroll_comparator = Comparator(
+            operator = reroll_desc['comparator'],
+            value = reroll_desc['target'],
         )
-
-    def roll(self) -> DiceRolled:
-        '''Roll dice according to specifications. Returns a DiceRolled object.'''
-        all_rolls = []
-        if self.reroll_spec:
-            for i in range(self.dice_count):
-                all_rolls.extend(self.reroll_spec.roll_die(self.die_type))
-        else:
-            for i in range(self.dice_count):
-                all_rolls.append(roll_die(self.die_type))
-        if self.drop_spec:
-            (dice_results, dropped_results) = self.drop_spec.drop_rolls(all_rolls)
-        else:
-            (dice_results, dropped_results) = (all_rolls, [])
-        success_count: OptionalType[int]
-        if self.success_comparator is not None:
-            success_count = 0
-            for roll in dice_results:
-                if self.success_comparator.compare(roll):
-                    success_count += 1
-            if self.failure_comparator is not None:
-                for roll in dice_results:
-                    if self.failure_comparator.compare(roll):
-                        success_count -= 1
+        for i in range(dice_count):
+            current_rolls = roll_die_with_rerolls(die_face, reroll_comparator, reroll_limit)
+            if len(current_rolls) == 1:
+                # If no rerolls happened, then just add the single
+                # roll as is.
+                kept_rolls.append(current_rolls[0])
+            elif reroll_type in ['r', 'R']:
+                # Keep only the last roll, and mark it as rerolled
+                kept_rolls.append(DieRolled(current_rolls[-1], '{}' + reroll_type))
+            elif reroll_type in ['!', '!!', '!p', '!!p']:
+                if reroll_type.endswith('p'):
+                    # For penetration, subtract 1 from all rolls
+                    # except the first
+                    for i in range(1, len(current_rolls)):
+                        current_rolls[i] -= 1
+                if reroll_type.startswith('!!'):
+                    # For compounding, return the sum, marked as a
+                    # compounded roll.
+                    kept_rolls.append(DieRolled(sum(current_rolls),
+                                               '{}' + reroll_type))
+                else:
+                    # For exploding, add each individual roll to the
+                    # list. Mark each roll except the last as
+                    # rerolled.
+                    for i in range(0, len(current_rolls) - 1):
+                        current_rolls[i] = DieRolled(current_rolls[i], '{}' + reroll_type)
+                    kept_rolls.extend(current_rolls)
+            else:
+                raise ValueError(f'Unknown reroll type: {reroll_type}')
+    else:
+        # Roll the requested number of dice
+        all_rolls = [ roll_die(die_face) for i in range(dice_count) ]
+        if 'drop_type' in roll_desc:
+            keep_count: int = roll_desc['keep_count']
+            keep_high: bool = roll_desc['keep_high']
+            # We just need to keep the highest/lowest N rolls. The
+            # extra complexity here is just to preserve the original
+            # order of those rolls.
+            rolls_to_keep = sorted(all_rolls, reverse = keep_high)[:keep_count]
+            kept_rolls = []
+            for roll in rolls_to_keep:
+                kept_rolls.append(all_rolls.pop(all_rolls.index(roll)))
+            # Remaining rolls are dropped
+            dropped_rolls = all_rolls
         else:
-            success_count = None
-        return DiceRolled(
-            dice_results=dice_results,
-            dropped_results=dropped_results,
-            roll_desc=str(self),
-            success_count=success_count,
+            kept_rolls = all_rolls
+    # Now we have a list of kept rolls
+    if 'count_success' in roll_desc:
+        die_face = int(die_face) # No fate dice
+        success_desc = roll_desc['count_success']
+        success_test = Comparator(
+            operator = success_desc['comparator'],
+            value = success_desc['target'],
         )
-
-def make_dice_roller(expr: Union[str,ParseResults]) -> DiceRoller:
-    if isinstance(expr, str):
-        expr = roll_spec.parseString(expr, True)['roll']
-    assert expr.getName() == 'roll'
-    expr = expr.asDict()
-
-    dtype = normalize_die_type(expr['die_type'])
-    dcount = normalize_dice_count(expr['dice_count'])
-    constructor_args: Dict[str, Any] = {
-        'die_type': dtype,
-        'dice_count': dcount,
-        'reroll_spec': None,
-        'drop_spec': None,
-        'success_comparator': None,
-        'failure_comparator': None,
-    }
-
-    rrdict = None
-    if 'reroll' in expr:
-        rrdict = expr['reroll']
-        constructor_args['reroll_spec'] = RerollSpec(**rrdict)
-
-    if 'drop' in expr:
-        ddict = expr['drop']
-        constructor_args['drop_spec'] = DropSpec(**ddict)
-
-    if 'count_successes' in expr:
-        csdict = expr['count_successes']
-        constructor_args['success_comparator'] = Comparator(**csdict['success_condition'])
-        if 'failure_condition' in csdict:
-            constructor_args['failure_comparator'] = Comparator(**csdict['failure_condition'])
-    return DiceRoller(**constructor_args)
+        success_count = sum(success_test(x) for x in kept_rolls)
+        if 'count_failure' in roll_desc:
+            failure_desc = roll_desc['count_failure']
+            failure_test = Comparator(
+                operator = failure_desc['comparator'],
+                value = failure_desc['target'],
+            )
+            # Make sure the two conditions don't overlap
+            for i in range(1, die_face + 1):
+                if success_test(i) and failure_test(i):
+                    raise ValueError(f"Can't use overlapping success and failure conditions: {str(success_test)!r}, {str(failure_test)!r}")
+            success_count -= sum(failure_test(x) for x in kept_rolls)
+    temp_args = dict(
+        dice_results = kept_rolls,
+        dropped_results = dropped_rolls,
+        success_count = success_count,
+        roll_text = roll_desc['roll_text'],
+    )
+    return DiceRolled(
+        dice_results = kept_rolls,
+        dropped_results = dropped_rolls,
+        success_count = success_count,
+        roll_text = roll_desc['roll_text'],
+    )
 
 # examples = [
 #     '1+1',
@@ -572,7 +601,6 @@ def make_dice_roller(expr: Union[str,ParseResults]) -> DiceRoller:
 #     '10d4!p',
 #     '20d6≥6',
 #     '8d12≥10f≤2',
-#     # '4d20R<=2!>=19Xx21>=20f<=5*2+3',  # Pretty much every possible feature
 # ]
 
 # example_results = {}
@@ -582,258 +610,272 @@ def make_dice_roller(expr: Union[str,ParseResults]) -> DiceRoller:
 #     except ParseException as ex:
 #         example_results[x] = ex
 # example_results
+class QuitRequested(BaseException):
+    pass
+
+class InputHandler(PTNodeVisitor):
+    def __init__(self, **kwargs):
+        self.env: Dict[str, str] = kwargs.pop('env', {})
+        self.recursed_vars: Set[str] = kwargs.pop('recursed_vars', set())
+        self.expr_parser = kwargs.pop('expr_parser', expr_parser)
+        self.print_results = kwargs.pop('print_results', True)
+        self.print_rolls = kwargs.pop('print_rolls', True)
+        super().__init__(**kwargs)
+
+    def visit_Whitespace(self, node, children):
+        '''Remove whitespace nodes'''
+        return None
+    def visit_Number(self, node, children):
+        '''Return the numeric value.
 
-# rs = RerollSpec('!!p', '=', 6)
-# rs.roll_die(6)
-
-# ds = DropSpec('K', 2)
-# ds.drop_rolls([1,2,3,4,5])
-# ds = DropSpec('x', 2)
-# ds.drop_rolls([1,2,3,4,5])
-
-# parse_roll = lambda x: expr_parser.parseString(x)[0]
-# exprstring = 'x + 1 + (2 + (3 + 4))'
-# expr = parse_roll(exprstring)
-
-# r = parse_roll('x + 1 - 2 * y * 4d4 + 2d20K1>=20f<=5')[0]
-
-op_dict: Dict[str, Callable] = {
-    '+': operator.add,
-    '-': operator.sub,
-    '*': operator.mul,
-    '×': operator.mul,
-    '/': operator.truediv,
-    '÷': operator.truediv,
-    '**': operator.pow,
-    '^': operator.pow,
-}
-
-ExprType = Union[float, str, ParseResults]
-
-def normalize_expr(expr: ExprType) -> ParseResults:
-    if isinstance(expr, str):
-        return expr_parser.parseString(expr)['expr']
-    elif isinstance(expr, Number):
-        return expr
-    else:
-        assert isinstance(expr, ParseResults)
-        return expr['expr']
-
-def _eval_expr_internal(
-        expr: ExprType,
-        env: Dict[str, str] = {},
-        print_rolls: bool = True,
-        recursed_vars: Set[str] = set()) -> float:
-    if isinstance(expr, float) or isinstance(expr, int):
-        # Numeric literal
-        return expr
-    elif isinstance(expr, str):
-        # variable name
-        if expr in recursed_vars:
-            raise ValueError('Recursive variable definition detected for {!r}'.format(expr))
-        elif expr in env:
-            var_value = env[expr]
-            parsed = normalize_expr(var_value)
-            return _eval_expr_internal(parsed, env, print_rolls,
-                                       recursed_vars = recursed_vars.union([expr]))
-        else:
-            raise ValueError('Expression referenced undefined variable {!r}'.format(expr))
-    else:
-        assert isinstance(expr, ParseResults)
-        if 'operator' in expr:
-            # Compound expression
-            operands = expr[::2]
-            operators = expr[1::2]
-            assert len(operands) == len(operators) + 1
-            values = [ _eval_expr_internal(x, env, print_rolls, recursed_vars)
-                       for x in operands ]
-            result = values[0]
-            for (op, nextval) in zip(operators, values[1:]):
-                opfun = op_dict[op]
-                result = opfun(result, nextval)
-            return result
-        else:
-            # roll specification
-            roller = make_dice_roller(expr)
-            rolled = roller.roll()
-            if print_rolls:
-                print(rolled)
-            return int(rolled)
-
-def eval_expr(expr: ExprType,
-              env: Dict[str,str] = {},
-              print_rolls: bool = True) -> float:
-    expr = normalize_expr(expr)
-    return _eval_expr_internal(expr, env, print_rolls)
-
-def _expr_as_str_internal(expr: ExprType,
-                          env: Dict[str,str] = {},
-                          recursed_vars: Set[str] = set()) -> str:
-    if isinstance(expr, float) or isinstance(expr, int):
-        return '{:g}'.format(expr)
-    elif isinstance(expr, str):
-        # variable name
-        if expr in recursed_vars:
-            raise ValueError('Recursive variable definition detected for {!r}'.format(expr))
-        elif expr in env:
-            var_value = env[expr]
-            parsed = normalize_expr(var_value)
-            return _expr_as_str_internal(parsed, env, recursed_vars = recursed_vars.union([expr]))
-        # Not a variable name, just a string
+        Uses int if possible, otherwise float.'''
+        try:
+            return int(node.value)
+        except ValueError:
+            return float(node.value)
+    def visit_NonzeroDigits(self, node, children):
+        return int(node.flat_str())
+    def visit_Integer(self, node, children):
+        return int(node.flat_str())
+    def visit_PercentileFace(self, node, children):
+        return 100
+    def visit_BasicRollExpr(self, node, children):
+        die_face = children[-1]
+        if isinstance(die_face, int) and die_face < 2:
+            raise ValueError(f"Invalid roll: Can't roll a {die_face}-sided die")
+        return {
+            'dice_count': children[0] if len(children) == 3 else 1,
+            'die_face': die_face,
+        }
+    def visit_DropSpec(self, node, children):
+        return {
+            'drop_type': children[0],
+            'drop_or_keep_count': children[1] if len(children) > 1 else 1,
+        }
+    def visit_RerollSpec(self, node, children):
+        if len(children) == 1:
+            return {
+                'reroll_type': children[0],
+                # The default reroll condition depends on other parts
+                # of the roll expression, so it will be "filled in"
+                # later.
+            }
+        elif len(children) == 2:
+            return {
+                'reroll_type': children[0],
+                'reroll_desc': {
+                    'comparator': '=',
+                    'target': children[1],
+                },
+            }
+        elif len(children) == 3:
+            return {
+                'reroll_type': children[0],
+                'reroll_desc': {
+                    'comparator': children[1],
+                    'target': children[2],
+                },
+            }
         else:
-            return expr
-    else:
-        assert isinstance(expr, ParseResults)
-        if 'operator' in expr:
-            # Compound expression
-            operands = expr[::2]
-            operators = expr[1::2]
-            assert len(operands) == len(operators) + 1
-            values = [ _expr_as_str_internal(x, env, recursed_vars)
-                       for x in operands ]
-            result = str(values[0])
-            for (op, nextval) in zip(operators, values[1:]):
-                result += ' {} {}'.format(op, nextval)
-            return '(' + result + ')'
+            raise ValueError("Invalid reroll specification")
+    def visit_Comparison(self, node, children):
+        return {
+            'comparator': children[0],
+            'target': children[1],
+        }
+    def visit_CountSpec(self, node, children):
+        result = { 'count_success': children[0], }
+        if len(children) > 1:
+            result['count_failure'] = children[1]
+        return result
+    def visit_RollExpr(self, node, children):
+        # Collect all child dicts into one
+        roll_desc = {
+            'roll_text': node.flat_str(),
+        }
+        for child in children:
+            roll_desc.update(child)
+        logger.debug(f'Initial roll description: {roll_desc!r}')
+        # Perform some validation that can only be done once the
+        # entire roll description is collected.
+        if not isinstance(roll_desc['die_face'], int):
+            if 'reroll_type' in roll_desc:
+                raise ValueError('Can only reroll/explode numeric dice, not Fate dice')
+            if 'count_success' in roll_desc:
+                raise ValueError('Can only count successes on numeric dice, not Fate dice')
+        # Fill in implicit reroll type
+        if 'reroll_type' in roll_desc and not 'reroll_desc' in roll_desc:
+            rrtype = roll_desc['reroll_type']
+            if rrtype in ['r', 'R']:
+                roll_desc['reroll_desc'] = {
+                    'comparator': '=',
+                    'target': 1,
+                }
+            else:
+                roll_desc['reroll_desc'] = {
+                    'comparator': '=',
+                    'target': roll_desc['die_face'],
+                }
+        # Validate drop spec and determine exactly how many dice to
+        # drop/keep
+        if 'drop_type' in roll_desc:
+            dtype = roll_desc['drop_type']
+            keeping = dtype in ['K', 'k']
+            if keeping:
+                roll_desc['keep_count'] = roll_desc['drop_or_keep_count']
+            else:
+                roll_desc['keep_count'] = roll_desc['dice_count'] - roll_desc['drop_or_keep_count']
+            if roll_desc['keep_count'] < 1:
+                drop_count = roll_desc['dice_count'] - roll_desc['keep_count']
+                raise ValueError(f"Can't drop {drop_count} dice out of {roll_desc['dice_count']}")
+            if roll_desc['keep_count'] >= roll_desc['dice_count']:
+                raise ValueError(f"Can't keep {roll_desc['keep_count']} dice out of {roll_desc['dice_count']}")
+            # Keeping high rolls is the same as dropping low rolls
+            roll_desc['keep_high'] = dtype in ['K', 'x', '-L']
+        # Validate count spec
+        elif 'count_failure' in roll_desc and not 'count_success' in roll_desc:
+            # The parser shouldn't allow this, but just in case
+            raise ValueError("Can't have a failure condition without a success condition")
+        logger.debug(f'Final roll description: {roll_desc!r}')
+        result = roll_dice(roll_desc)
+        if self.print_rolls:
+            print(str(result))
+        return int(result)
+    def visit_Identifier(self, node, children):
+        '''Interpolate variable.'''
+        var_name = node.value
+        if var_name in self.recursed_vars:
+            raise ValueError(f'Recursive variable definition detected for {var_name!r}')
+        try:
+            var_expression = self.env[var_name]
+        except KeyError as ex:
+            raise UndefinedVariableError(*ex.args)
+        recursive_evaluator = copy(self)
+        recursive_evaluator.recursed_vars = self.recursed_vars.union([var_name])
+        # Don't print the results of evaluating variables
+        recursive_evaluator.print_results = False
+        if self.debug:
+            self.dprint(f'Evaluating variable {var_name} with expression {var_expression!r}')
+        return self.expr_parser.parse(var_expression).visit(recursive_evaluator)
+    def visit_Expression(self, node, children):
+        if self.print_results:
+            print('Result: {result} (rolled {expr})'.format(
+                expr=color(node.flat_str(), EXPR_COLOR),
+                result=color(f'{children[0]:g}', RESULT_COLOR),
+            ))
+        return children[0]
+    # Each of these returns a tuple of (operator, value)
+    def visit_Add(self, node, children):
+        return (operator.add, children[-1])
+    def visit_Sub(self, node, children):
+        return (operator.sub, children[-1])
+    def visit_Mul(self, node, children):
+        return (operator.mul, children[-1])
+    def visit_Div(self, node, children):
+        return (operator.truediv, children[-1])
+    def visit_Exponent(self, node, children):
+        return (operator.pow, children[-1])
+    # Each of these receives a first child that is a number and the
+    # remaining children are tuples of (operator, number)
+    def visit_SumExpr(self, node, children):
+        values = [children[0]]
+        ops = []
+        for (op, val) in children[1:]:
+            values.append(val)
+            ops.append(op)
+        if self.debug:
+            self.dprint(f'Sum: values: {values!r}; ops: {ops!r}')
+        return eval_infix(values, ops, 'l')
+    def visit_ProductExpr(self, node, children):
+        values = [children[0]]
+        ops = []
+        for (op, val) in children[1:]:
+            values.append(val)
+            ops.append(op)
+        if self.debug:
+            self.dprint(f'Product: values: {values!r}; ops: {ops!r}')
+        return eval_infix(values, ops, 'l')
+    def visit_ExponentExpr(self, node, children):
+        values = [children[0]]
+        ops = []
+        for (op, val) in children[1:]:
+            values.append(val)
+            ops.append(op)
+        if self.debug:
+            self.dprint(f'Exponent: values: {values!r}; ops: {ops!r}')
+        return eval_infix(values, ops, 'l')
+    def visit_Sign(self, node, children):
+        if node.value == '-':
+            return -1
         else:
-            # roll specification
-            return str(make_dice_roller(expr))
-
-def expr_as_str(expr: ExprType, env: Dict[str,str]  = {}) -> str:
-    expr = normalize_expr(expr)
-    expr = _expr_as_str_internal(expr, env)
-    if expr.startswith('(') and expr.endswith(')'):
-        expr = expr[1:-1]
-    return expr
-
-def read_roll(handle: TextIO = sys.stdin) -> str:
+            return 1
+    def visit_ParenExpr(self, node, children):
+        assert len(children) > 0
+        # Multiply the sign (if present) and the value inside the
+        # parens
+        return functools.reduce(operator.mul, children)
+    def visit_VarAssignment(self, node, children):
+        logger.debug(f'Doing variable assignment: {node.flat_str()}')
+        var_name, var_value = children
+        print('Saving "{var}" as "{expr}"'.format(
+            var=color(var_name, RESULT_COLOR),
+            expr=color(var_value, EXPR_COLOR),
+        ))
+        self.env[var_name] = var_value
+    def visit_ListVarsCommand(self, node, children):
+        print_vars(self.env)
+    def visit_DeleteCommand(self, node, children):
+        var_name = children[-1]
+        print('Deleting saved value for "{var}".'.format(
+            var=color(var_name, RESULT_COLOR)))
+        try:
+            self.env.pop(var_name)
+        except KeyError as ex:
+            raise UndefinedVariableError(*ex.args)
+    def visit_HelpCommand(self, node, children):
+        print_interactive_help()
+    def visit_QuitCommand(self, node, children):
+        raise QuitRequested()
+
+# def handle_input(expr: str, **kwargs) -> float:
+#     return input_parser.parse(expr).visit(InputHandler(**kwargs))
+
+# handle_input('help')
+# handle_input('2+2 * 2 ** 2')
+# env = {}
+# handle_input('y = 2 + 2', env = env)
+# handle_input('x = y + 2', env = env)
+# handle_input('2 + x', env = env)
+# handle_input('del x', env = env)
+# handle_input('vars', env = env)
+# handle_input('2 + x', env = env)
+# handle_input('d4 = 5', env = env)
+
+def read_input(handle: TextIO = sys.stdin) -> str:
     if handle == sys.stdin:
         return input("Enter roll> ")
     else:
         return handle.readline()[:-1]
 
-special_command_parser: ParserElement = (
-    oneOf('h help ?').setResultsName('help') |
-    oneOf('q quit exit').setResultsName('quit') |
-    oneOf('v vars').setResultsName('vars') |
-    (oneOf('d del delete').setResultsName('delete').leaveWhitespace() + Suppress(White()) + var_name)
-)
-
-def var_name_allowed(vname: str) -> bool:
-    '''Disallow variable names like 'help' and 'quit'.'''
-    parsers = [ special_command_parser, roll_spec ]
-    for parser in [ special_command_parser, roll_spec ]:
-        try:
-            parser.parseString(vname, True)
-            return False
-        except ParseException:
-            pass
-    # If the variable name didn't parse as anything else, it's valid
-    return True
-
-line_parser: ParserElement = (
-    special_command_parser ^
-    (assignment_parser | expr_parser)
-)
-
-def print_interactive_help() -> None:
-    print('\n' + '''
-
-To make a roll, type in the roll in dice notation, e.g. '4d4 + 4'.
-Nearly all dice notation forms listed in the following references should be supported:
-
-- http://rpg.greenimp.co.uk/dice-roller/
-- https://www.critdice.com/roll-advanced-dice
-
-Expressions can include numeric constants, addition, subtraction,
-multiplication, division, and exponentiation.
-
-To assign a variable, use 'VAR = VALUE'. For example 'health_potion =
-2d4+2'. Subsequent roll expressions (and other variables) can refer to
-this variable, whose value will be substituted in to the expression.
-
-If a variable's value includes any dice rolls, those dice will be
-rolled (and produce a different value) every time the variable is
-used.
-
-Special commands:
-
-- To show the values of all currently assigned variables, type 'vars'.
-- To delete a previously defined variable, type 'del VAR'.
-- To show this help text, type 'help'.
-- To quit, type 'quit'.
-
-    '''.strip() + '\n', file=sys.stdout)
-
-def print_vars(env: Dict[str,str]) -> None:
-    if len(env):
-        print('Currently defined variables:')
-        for k in sorted(env.keys()):
-            print('{} = {!r}'.format(k, env[k]))
-    else:
-        print('No variables are currently defined.')
-
 if __name__ == '__main__':
     expr_string = " ".join(sys.argv[1:])
     if re.search("\\S", expr_string):
         try:
-            # Note: using expr_parser instead of line_parser, because
-            # on the command line only roll expressions are valid.
-            expr = expr_parser.parseString(expr_string, True)
-            result = eval_expr(expr)
-            print('Result: {result} (rolled {expr})'.format(
-                expr=color(expr_as_str(expr), EXPR_COLOR),
-                result=color("{:g}".format(result), RESULT_COLOR),
-            ))
+            expr_parser.parse(expr_string).visit(InputHandler())
         except Exception as exc:
             logger.error("Error while rolling: %s", repr(exc))
             raise exc
             sys.exit(1)
     else:
         env: Dict[str, str] = {}
+        handler = InputHandler(env = env)
         while True:
             try:
-                expr_string = read_roll()
-                if not re.search("\\S", expr_string):
-                    continue
-                parsed = line_parser.parseString(expr_string, True)
-                if 'help' in parsed:
-                    print_interactive_help()
-                elif 'quit' in parsed:
-                    logger.info('Quitting.')
-                    break
-                elif 'vars' in parsed:
-                    print_vars(env)
-                elif 'delete' in parsed:
-                    vname = parsed['varname']
-                    if vname in env:
-                        print('Deleting saved value for "{var}".'.format(var=color(vname, RESULT_COLOR)))
-                        del env[vname]
-                    else:
-                        logger.error('Variable "{var}" is not defined.'.format(var=color(vname, RESULT_COLOR)))
-                elif re.search("\\S", expr_string):
-                    if 'assignment' in parsed:
-                        # We have an assignment operation
-                        vname = parsed['varname']
-                        if var_name_allowed(vname):
-                            env[vname] = expr_as_str(parsed['expr'])
-                            print('Saving "{var}" as "{expr}"'.format(
-                                var=color(vname, RESULT_COLOR),
-                                expr=color(env[vname], EXPR_COLOR),
-                            ))
-                        else:
-                            logger.error('You cannot use {!r} as a variable name.'.format(vname))
-                    else:
-                        # Just an expression to evaluate
-                        result = eval_expr(parsed, env)
-                        print('Result: {result} (rolled {expr})'.format(
-                            expr=color(expr_as_str(parsed, env), EXPR_COLOR),
-                            result=color("{:g}".format(result), RESULT_COLOR),
-                        ))
-                print('')
+                input_string = read_input()
+                input_parser.parse(input_string).visit(handler)
             except KeyboardInterrupt:
                 print('')
-            except EOFError:
+            except (EOFError, QuitRequested):
                 print('')
                 logger.info('Quitting.')
                 break