Repository: maxfischer2781/bootpeg (language: python)

Issue:
Add start/end of source terminals
Similar to the special terminals ``Anything``/``.``, ``Nothing``/``ε``, add terminals for start and end of the source. These should implicitly match at start/end position, respectively.

Possible standard symbols are the RegEx ``^``/``$`` for start/end.
Parsing Algorithm: PickaTwo with Bounded Recursion
The current Pika parsing algorithm provides stable left recursion, but with somewhat impractical and (to me) unexpected behaviour. The behaviour boils down to the precedence of recursion versus choices. Given a grammar like
```
expr:
    | expr '+' term
    | expr '-' term
    | term
```
once Pika matches `expr '+' term` it cannot use this in the lower-order choice `expr '-' term`. This means Pika can match `a + b + c`, `a - b + c`, and `a - b - c` but somewhat surprisingly not `a + b -c`.

For classical PEG, ["Left Recursion in Parsing Expression Grammars" by Medeiros, Mascarenhas, Ierusalimschy](https://arxiv.org/abs/1207.0443) suggests a different scheme called Bounded Recursion: During recursion a new match takes precedence over an older one regardless of choice order.  
The key parts of BR are:
1) A new match that is longer than a previous match wins. A shorter or equal match ends recursion.
2) Only rules are memoized, not clauses.

Part 1) is similar to how Pika works, and in fact appears to be *equivalent* due to the matching order. Part 2) is a major deviation, as Pika relies on the *clause* memo table to match everything but terminals; however, it is required to not shadow later order choices with earlier ones.

-----------------------------------

To get the simplicity of Pika with the power of Bounded Recursion, I suggest a hybrid algorithm *PikaTwo* (\*ahem\*). This matches *clauses* in top-down order, but *rules* in bottom-up order.

The larger logical framework remains the same, but in most cases we swap "clauses" for "rules". For example, the core algorithm starts by matching terminal *rules* (those that can match without other rules) and triggers non-terminal rules.

-----------------------------------

See also https://github.com/lukehutch/pikaparser/issues/32 for background discussion on Pika left recursion.


Discussion / hints:
The canonical PEG uses ``!.``, or "not anything", to match the end of source.
Explicitly implementing "end of source" would be useful. Since ``!.`` is ``maybe_zero``, we cannot require it via ``~ !.``.


Fix patch:
diff --git a/bootpeg/__init__.py b/bootpeg/__init__.py
index 64c425e..29b52d4 100644
--- a/bootpeg/__init__.py
+++ b/bootpeg/__init__.py
@@ -1,6 +1,6 @@
 """bootpeg – the bootstrapping PEG parser"""
-from .api import parse, create_parser, import_parser
+from .api import create_parser, import_parser, bootpeg_actions
 
-__all__ = ["parse", "create_parser", "import_parser"]
+__all__ = ["create_parser", "import_parser", "bootpeg_actions"]
 
-__version__ = "0.6.0"
+__version__ = "0.7.0-alpha"
diff --git a/bootpeg/apegs/__init__.py b/bootpeg/apegs/__init__.py
new file mode 100644
index 0000000..d98d028
--- /dev/null
+++ b/bootpeg/apegs/__init__.py
@@ -0,0 +1,50 @@
+from .clauses import (
+    Value,
+    Range,
+    Empty,
+    Any,
+    Sequence,
+    Choice,
+    Repeat,
+    Not,
+    And,
+    Entail,
+    Capture,
+    Transform,
+    Reference,
+    Rule,
+)
+from .front import (
+    ParseFailure,
+    Grammar,
+    Parser,
+)
+from .boot import (
+    apegs_actions,
+    bpeg_parser,
+)
+
+__all__ = [
+    # clauses
+    "Value",
+    "Range",
+    "Empty",
+    "Any",
+    "Sequence",
+    "Choice",
+    "Repeat",
+    "Not",
+    "And",
+    "Entail",
+    "Capture",
+    "Transform",
+    "Reference",
+    "Rule",
+    # front
+    "ParseFailure",
+    "Grammar",
+    "Parser",
+    # boot
+    "apegs_actions",
+    "bpeg_parser",
+]
diff --git a/bootpeg/apegs/boot.py b/bootpeg/apegs/boot.py
new file mode 100644
index 0000000..411907d
--- /dev/null
+++ b/bootpeg/apegs/boot.py
@@ -0,0 +1,264 @@
+"""
+Minimal parser required to bootstrap entire bootpeg parser
+"""
+import string
+
+import importlib_resources
+
+from .clauses import (
+    Value,
+    Range,
+    Any,
+    Empty,
+    Sequence,
+    Choice,
+    Repeat,
+    And,
+    Not,
+    Entail,
+    Capture,
+    Transform,
+    Reference,
+    Rule,
+)
+from .interpret import Clause
+from .front import Parser, Grammar
+
+
+def apply(__func: str, **captures) -> Transform:
+    """Helper to create a Transform and Captures from keywords"""
+    return Transform(
+        Sequence(
+            *(
+                Capture(clause, name, False) if not name[0] == "_" else clause
+                for name, clause in captures.items()
+            )
+        ),
+        __func,
+    )
+
+
+apegs_actions = {
+    clause.__name__: clause
+    for clause in (
+        Value,
+        Range,
+        Any,
+        Empty,
+        Sequence,
+        Choice,
+        Repeat,
+        And,
+        Not,
+        Entail,
+        Capture,
+        Transform,
+        Reference,
+        Rule,
+        Grammar,
+        Parser,
+    )
+}
+
+
+# derived clauses
+def neg(*clauses: Clause):
+    return Sequence(*(Not(clause) for clause in clauses), Any(1))
+
+
+spaces: Choice[str] = Choice(Value(" "), Empty())
+
+boot_parser: Parser[str, Grammar] = Parser(
+    Rule(
+        "top",
+        Sequence(
+            Transform(
+                Capture(
+                    Repeat(Choice(Reference("rule"), Reference("end_line"))),
+                    "rules",
+                    variadic=True,
+                ),
+                "Grammar(*rules)",
+            ),
+            Not(Any(1)),
+        ),
+    ),
+    Rule(
+        "end_line",
+        Sequence(
+            spaces,
+            Choice(Sequence(Value("#"), Repeat(neg(Value("\n")))), Empty()),
+            Choice(Value("\n"), Not(Any(1))),
+        ),
+    ),
+    Rule(
+        "identifier", Repeat(Choice(*(Value(ch) for ch in string.ascii_letters + "_")))
+    ),
+    Rule(
+        "literal",
+        Choice(
+            *(
+                Sequence(Value(quote), Repeat(neg(Value(quote))), Entail(Value(quote)))
+                for quote in ('"', "'")
+            )
+        ),
+    ),
+    # rule matching
+    Rule(
+        "action_body",
+        Repeat(
+            Choice(
+                neg(Value("{"), Value("}")),
+                Sequence(Value("{"), Reference("action_body"), Entail(Value("}"))),
+            )
+        ),
+    ),
+    Rule(
+        "action",
+        apply(
+            "body", _h=Value("{"), body=Reference("action_body"), _t=Entail(Value("}"))
+        ),
+    ),
+    Rule(
+        "rule_choice",
+        Choice(
+            apply(
+                "Transform(expr, action)",
+                _h=Value("| "),
+                expr=Reference("expr"),
+                _s=spaces,
+                action=Reference("action"),
+            ),
+            Sequence(Value("| "), Reference("expr")),
+        ),
+    ),
+    Rule(
+        "rule_body",
+        Choice(
+            apply(
+                "Choice(first, otherwise)",
+                first=Reference("rule_body"),
+                otherwise=Sequence(
+                    Value("    "), Reference("rule_choice"), Reference("end_line")
+                ),
+            ),
+            Sequence(Value("    "), Reference("rule_choice"), Reference("end_line")),
+        ),
+    ),
+    Rule(
+        "rule",
+        apply(
+            "Rule(name, body)",
+            name=Reference("identifier"),
+            _=Sequence(Value(":"), Reference("end_line")),
+            body=Reference("rule_body"),
+        ),
+    ),
+    # atomic clauses without sub-clauses
+    Rule(
+        "atom",
+        Choice(
+            Transform(Choice(Value('""'), Value("''")), "Empty()"),
+            Transform(Value("."), "Any(1)"),
+            Transform(Value("\\n"), "Value('\\n')"),
+            apply(
+                "Range(lower[1:-1], upper[1:-1])",
+                lower=Reference("literal"),
+                _=Sequence(spaces, Value("-"), spaces),
+                upper=Entail(Reference("literal")),
+            ),
+            apply("Value(literal[1:-1])", literal=Reference("literal")),
+            apply("Reference(name)", name=Reference("identifier")),
+        ),
+    ),
+    # clauses with unambiguous, non-zero prefix
+    Rule(
+        "prefix",
+        Choice(
+            apply("Not(expr)", _=Value("!"), expr=Entail(Reference("prefix"))),
+            Sequence(
+                Value("("),
+                spaces,
+                Entail(Sequence(Reference("expr"), spaces, Value(")"))),
+            ),
+            apply(
+                "Choice(expr, Empty())",
+                expr=Sequence(
+                    Value("["),
+                    spaces,
+                    Entail(Sequence(Reference("expr"), spaces, Value("]"))),
+                ),
+            ),
+            Reference("atom"),
+        ),
+    ),
+    Rule(
+        "repeat",
+        Choice(
+            apply("Repeat(expr)", expr=Reference("prefix"), _=Value("+")),
+            apply(
+                "Choice(Repeat(expr), Empty())", expr=Reference("prefix"), _=Value("*")
+            ),
+            Reference("prefix"),
+        ),
+    ),
+    Rule(
+        "capture",
+        Choice(
+            apply(
+                "Capture(expr, name, variadic)",
+                variadic=Choice(
+                    Transform(Value("*"), "True"),
+                    Transform(Empty(), "False"),
+                ),
+                name=Reference("identifier"),
+                _=Value("="),
+                expr=Entail(Reference("repeat")),
+            ),
+            Reference("repeat"),
+        ),
+    ),
+    Rule(
+        "sequence",
+        Choice(
+            apply(
+                "Sequence(head, tail)",
+                head=Reference("sequence"),
+                _=spaces,
+                tail=Reference("capture"),
+            ),
+            apply(
+                "Sequence(head, Entail(tail))",
+                head=Reference("sequence"),
+                _=Sequence(spaces, Value("~"), spaces),
+                tail=Entail(Reference("sequence")),
+            ),
+            apply(
+                "Entail(seq)",
+                seq=Sequence(Value("~"), spaces, Entail(Reference("sequence"))),
+            ),
+            Reference("capture"),
+        ),
+    ),
+    Rule(
+        "choice",
+        Choice(
+            apply(
+                "Choice(first, otherwise)",
+                first=Reference("choice"),
+                _=Sequence(spaces, Value("|"), spaces),
+                otherwise=Entail(Reference("sequence")),
+            ),
+            Reference("sequence"),
+        ),
+    ),
+    Rule(
+        "expr",
+        Reference("choice"),
+    ),
+    **apegs_actions,
+)
+
+bpeg_parser: Parser[str, Grammar] = boot_parser(
+    importlib_resources.read_text("bootpeg.grammars", "bpeg.bpeg")
+).parser(**apegs_actions)
diff --git a/bootpeg/apegs/clauses.py b/bootpeg/apegs/clauses.py
new file mode 100644
index 0000000..69c6cc9
--- /dev/null
+++ b/bootpeg/apegs/clauses.py
@@ -0,0 +1,168 @@
+"""
+Abstract clauses understood by the PikaTwo parsing algorithm
+"""
+from typing import Generic, Union
+
+from ..typing import D
+from ..utility import slotted
+
+
+Clause = Union[
+    "Value[D]",
+    "Range[D]",
+    "Empty[D]",
+    "Any[D]",
+    "Sequence[D]",
+    "Choice[D]",
+    "Repeat[D]",
+    "Not[D]",
+    "And[D]",
+    "Entail[D]",
+    "Capture[D]",
+    "Transform[D]",
+    "Reference[D]",
+]
+
+
+@slotted
+class Value(Generic[D]):
+    """A terminal clause, matching a predefined value"""
+
+    __slots__ = ("value",)
+
+    def __init__(self, value: D):
+        self.value = value
+
+
+@slotted
+class Range(Generic[D]):
+    """A terminal clause, matching an inclusive range of predefined values"""
+
+    __slots__ = ("lower", "upper")
+
+    def __init__(self, lower: D, upper: D):
+        if len(lower) != len(upper):
+            raise ValueError(f"Bounds must be of same length, got {lower} and {upper}")
+        self.lower, self.upper = (lower, upper) if lower < upper else (upper, lower)
+
+
+@slotted
+class Empty(Generic[D]):
+    """The zero-length terminal ε, matching everywhere in the input"""
+
+    __slots__ = ()
+
+
+@slotted
+class Any(Generic[D]):
+    """Any fixed length terminal, matching at any point with sufficient remainder"""
+
+    __slots__ = ("length",)
+
+    def __init__(self, length: int):
+        assert length > 0
+        self.length = length
+
+
+@slotted
+class Sequence(Generic[D]):
+    """A sequence of clauses, matching if all ``sub_clauses`` match in order"""
+
+    __slots__ = ("sub_clauses",)
+
+    def __init__(self, *sub_clauses: "Clause[D]"):
+        self.sub_clauses = sub_clauses
+
+
+@slotted
+class Entail(Generic[D]):
+    """Mark ``sub_clauses`` as inevitable by the match so far, making failure fatal"""
+
+    __slots__ = ("sub_clauses",)
+
+    def __init__(self, *sub_clauses: "Clause[D]"):
+        self.sub_clauses = sub_clauses
+
+
+@slotted
+class Choice(Generic[D]):
+    """A choice of clauses, matching the first matching clause of ``sub_clauses``"""
+
+    __slots__ = ("sub_clauses",)
+
+    def __init__(self, *sub_clauses: "Clause[D]"):
+        self.sub_clauses = sub_clauses
+
+
+@slotted
+class Repeat(Generic[D]):
+    """A repetition of a clause, matching if the ``sub_clause`` matches at least once"""
+
+    __slots__ = ("sub_clause",)
+
+    def __init__(self, sub_clause: "Clause[D]"):
+        self.sub_clause = sub_clause
+
+
+@slotted
+class Not(Generic[D]):
+    """The inversion of a clause, matching if the ``sub_clause`` does not match"""
+
+    __slots__ = ("sub_clause",)
+
+    def __init__(self, sub_clause: "Clause[D]"):
+        self.sub_clause = sub_clause
+
+
+@slotted
+class And(Generic[D]):
+    """The lookahead of a clause, matching with the ``sub_clause`` without advancing"""
+
+    __slots__ = ("sub_clause",)
+
+    def __init__(self, sub_clause: "Clause[D]"):
+        self.sub_clause = sub_clause
+
+
+@slotted
+class Capture(Generic[D]):
+    """The capturing of a clause match result with a name"""
+
+    __slots__ = ("sub_clause", "name", "variadic")
+
+    def __init__(self, sub_clause: "Clause[D]", name, variadic):
+        self.sub_clause = sub_clause
+        self.name = name
+        self.variadic = variadic
+
+
+@slotted
+class Transform(Generic[D]):
+    """The transformation of a clause match result"""
+
+    __slots__ = ("sub_clause", "action")
+
+    def __init__(self, sub_clause: "Clause[D]", action: str):
+        self.sub_clause = sub_clause
+        self.action = action
+
+
+@slotted
+class Reference(Generic[D]):
+    """Reference to a named clause, i.e. a rule"""
+
+    __slots__ = ("name",)
+
+    def __init__(self, name: str):
+        self.name = name
+
+
+@slotted
+class Rule(Generic[D]):
+    """A named clause"""
+
+    __slots__ = ("name", "sub_clause")
+
+    def __init__(self, name: str, sub_clause: "Clause[D]"):
+        self.name = name
+        self.sub_clause = sub_clause
diff --git a/bootpeg/apegs/front.py b/bootpeg/apegs/front.py
new file mode 100644
index 0000000..105778e
--- /dev/null
+++ b/bootpeg/apegs/front.py
@@ -0,0 +1,155 @@
+from typing import Union, Tuple, Optional, Any as AnyT, Dict, Generic
+
+from ..typing import D, R
+from .clauses import Rule
+from .interpret import (
+    MatchFailure,
+    FatalMatchFailure,
+    Reference,
+    MatchClause,
+    Match,
+    match_clause,
+)
+
+
+AnyMatchFailure = Union[MatchFailure, FatalMatchFailure]
+
+
+def as_str(source: Union[str, bytes]) -> str:
+    if isinstance(source, str):
+        return source
+    return source.decode("latin-1")
+
+
+def context(source: D, index: int) -> Tuple[str, str]:
+    """Provide the context of ``source`` around ``index``"""
+    if isinstance(source, (str, bytes)):
+        new_line = "\n" if isinstance(source, str) else b"\n"
+        line_start = source.rfind(new_line, 0, index) + 1
+        line_end = source.find(new_line, index)
+        if line_end == -1:
+            line_end = None
+        return as_str(source[line_start:index]), as_str(source[index:line_end])
+    else:
+        return str(source[index - 5 : index]), str(source[index : index + 5])
+
+
+class ParseFailure(Exception):
+    __slots__ = ("message", "source", "index", "path")
+
+    def __init__(self, message: str, source: D, index: int, path: Tuple[str, ...]):
+        self.message = message
+        self.source = source
+        self.index = index
+        self.path = path
+
+    def __str__(self):
+        head, tail = context(self.source, self.index)
+        return "\n".join(
+            (
+                f"in path {' -> '.join(self.path) if self.path else '[start]'}",
+                self.message,
+                f"{' ' * len(head)}v-[at index {self.index}]",
+                head + tail,
+            )
+        )
+
+
+def walk_failures(err: AnyMatchFailure):
+    yield err
+    while err.__cause__ is not None:
+        err = err.__cause__
+        yield err
+        if not isinstance(err, (MatchFailure, FatalMatchFailure)):
+            break
+
+
+def report(source: D, err: AnyMatchFailure):
+    failures = list(walk_failures(err))
+    failure, cause = (
+        (failures[-1], None)
+        if isinstance(failures[-1], (MatchFailure, FatalMatchFailure))
+        else failures[-2:]
+    )  # type: AnyMatchFailure, Optional[AnyMatchFailure]
+    reference_path = tuple(
+        mf.clause.name
+        for mf in failures[: -1 if cause is None else -2]
+        if isinstance(mf.clause, Reference)
+    )
+    reason = (
+        f"transforming {failure.clause} failed ({type(cause)}: {cause})"
+        if cause is not None
+        else f"expected {failure.clause}"
+    )
+    raise ParseFailure(reason, source, failure.at, reference_path) from cause
+
+
+def unpack(match: Match) -> AnyT:
+    """Unpack the result of a top-level match with error reporting"""
+    if match.captures:
+        raise ValueError(
+            f"found {len(match.captures)} unused captures after parsing\n"
+            "hint: transform captured values to a mapping if they should be returned"
+        )
+    elif len(match.results) > 1:
+        raise ValueError(
+            f"found {len(match.results)} unused matches after parsing\n"
+            "hint: transform matches into a sequence if they should be returned"
+        )
+    elif not match.results:
+        raise ValueError(
+            "found no resulting match after parsing\n"
+            "hint: transform empty matches to a literal True or () to indicate success"
+        )
+    else:
+        return match.results[0]
+
+
+class Parser(Generic[D, R]):
+    """
+    Parser to transform some input by matching ``rules`` and applying ``actions``
+    """
+
+    __slots__ = ("_top", "rules", "actions", "_match_top", "_match_rules")
+
+    @property
+    def top(self) -> str:
+        return self._top
+
+    def __init__(self, __top: Rule[D], *rules: Rule[D], **actions: AnyT):
+        self._top = __top.name
+        self.rules = (__top, *rules)
+        self.actions = actions
+        self._match_top = match_clause(Reference(self.top), actions)
+        self._match_rules: Dict[str, MatchClause] = {
+            rule.name: match_clause(rule.sub_clause, actions) for rule in self.rules
+        }
+
+    def __call__(self, source: D) -> R:
+        try:
+            return unpack(
+                self._match_top(of=source, at=0, memo={}, rules=self._match_rules)
+            )
+        except (MatchFailure, FatalMatchFailure) as mf:
+            raise report(source, mf)
+
+    def __getstate__(self):
+        return {"rules": self.rules, "actions": self.actions}
+
+    def __setstate__(self, state):
+        self.__init__(*state["rules"], **state["actions"])
+
+
+class Grammar(Generic[D]):
+    """
+    Grammar describing how to match some input using ``rules``
+    """
+
+    __slots__ = ("rules",)
+
+    def __init__(self, *rules: Rule[D]):
+        self.rules = rules
+
+    def parser(self, **_globals: AnyT) -> Parser[D, AnyT]:
+        """Create a :py:class:`Parser` for the grammar"""
+        return Parser(*self.rules, **_globals)
diff --git a/bootpeg/apegs/interpret.py b/bootpeg/apegs/interpret.py
new file mode 100644
index 0000000..fbee5ec
--- /dev/null
+++ b/bootpeg/apegs/interpret.py
@@ -0,0 +1,375 @@
+"""
+Matching of clauses based on interpretation
+"""
+from typing import (
+    Mapping,
+    MutableMapping,
+    Tuple,
+    Any as AnyT,
+    NamedTuple,
+    Union,
+    Optional,
+    Callable,
+    Set,
+)
+from typing_extensions import Protocol
+
+from functools import singledispatch
+
+from ..typing import D, D_contra
+from .clauses import (
+    Value,
+    Range,
+    Empty,
+    Any,
+    Sequence,
+    Choice,
+    Repeat,
+    Not,
+    And,
+    Entail,
+    Capture,
+    Transform,
+    Reference,
+)
+
+
+Clause = Union[
+    Value[D],
+    Range[D],
+    Empty[D],
+    Any[D],
+    Sequence[D],
+    Choice[D],
+    Repeat[D],
+    Not[D],
+    And[D],
+    Entail[D],
+    Capture[D],
+    Transform[D],
+    Reference[D],
+]
+
+
+class MatchFailure(Exception):
+    __slots__ = ("at", "clause")
+
+    def __init__(self, at: int, clause: Clause):
+        self.at = at
+        self.clause = clause
+
+
+class FatalMatchFailure(Exception):
+    __slots__ = ("at", "clause")
+
+    def __init__(self, at: int, clause: Clause):
+        self.at = at
+        self.clause = clause
+
+
+@singledispatch
+def discover_captures(clause: Clause) -> Set[str]:
+    raise NotImplementedError(f"discover_captures for type({clause!r})")
+
+
+@discover_captures.register(Value)
+@discover_captures.register(Range)
+@discover_captures.register(Any)
+@discover_captures.register(Empty)
+@discover_captures.register(And)
+@discover_captures.register(Not)
+@discover_captures.register(Reference)
+@discover_captures.register(Transform)
+def _(clause) -> Set[str]:
+    return set()
+
+
+@discover_captures.register(Sequence)
+@discover_captures.register(Entail)
+def _(clause: Union[Sequence, Entail]) -> Set[str]:
+    return set.union(*map(discover_captures, clause.sub_clauses))
+
+
+@discover_captures.register(Choice)
+def _(clause: Choice) -> Set[str]:
+    clause_captures = discover_captures(clause.sub_clauses[0])
+    for sub_clause in clause.sub_clauses[1:]:
+        unique_captures = clause_captures ^ discover_captures(sub_clause)
+        if unique_captures:
+            raise Value(
+                f"Names {', '.join(unique_captures)} not captured "
+                f"in all choices of {clause!r}"
+            )
+    return clause_captures
+
+
+@discover_captures.register(Repeat)
+def _(clause: Repeat) -> Set[str]:
+    return discover_captures(clause.sub_clause)
+
+
+@discover_captures.register(Capture)
+def _(clause: Capture) -> Set[str]:
+    return {clause.name}
+
+
+def py_transform(clause: Transform, _globals: dict) -> Callable:
+    """Create a ``lambda`` to execute a transform given some globals"""
+    captures = discover_captures(clause.sub_clause)
+    source = f"lambda {', '.join(captures)}: {clause.action.strip()}"
+    code = compile(source, source, "eval")
+    return eval(code, _globals)
+
+
+class Match(NamedTuple):
+    at: int
+    length: int = 0
+    results: Tuple[AnyT, ...] = ()
+    captures: Tuple[Tuple[str, AnyT], ...] = ()
+
+    @property
+    def end(self):
+        return self.at + self.length
+
+    def __add__(self, other: "Match") -> "Match":
+        """Join two adjacent matches"""
+        assert (
+            self.end == other.at
+        ), f"not adjacent: {self.at + self.length} vs {other.at} ({self} vs {other})"
+        return Match(
+            at=self.at,
+            length=self.length + other.length,
+            results=self.results + other.results,
+            captures=self.captures + other.captures,
+        )
+
+    def copy(self) -> "Match":
+        return Match(*self)
+
+
+Memo = MutableMapping[Tuple[int, str], Optional[Match]]
+Rules = Mapping[str, "MatchClause"]
+
+
+class MatchClause(Protocol[D_contra]):
+    def __call__(self, of: D_contra, at: int, memo: Memo, rules: Rules) -> Match:
+        ...
+
+
+@singledispatch
+def match_clause(clause: Clause[D], _globals: dict) -> MatchClause[D]:
+    """Create a callable to match `clause`"""
+    raise NotImplementedError(f"match_clause for type({clause!r})")
+
+
+@match_clause.register(Value)
+def _(clause: Value[D], _globals: dict) -> MatchClause[D]:
+    value = clause.value
+    length = len(value)
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        if of[at : at + length] == value:
+            return Match(at, length)
+        raise MatchFailure(at, clause) from None
+
+    return do_match
+
+
+@match_clause.register(Range)
+def _(clause: Range[D], _globals: dict) -> MatchClause[D]:
+    lower = clause.lower
+    upper = clause.upper
+    length = len(lower)
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        candidate = of[at : at + length]
+        if len(candidate) == length and lower <= candidate <= upper:
+            return Match(at, length)
+        raise MatchFailure(at, clause) from None
+
+    return do_match
+
+
+@match_clause.register(Empty)
+def _(clause: Empty[D], _globals: dict) -> MatchClause[D]:
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        return Match(at, 0)
+
+    return do_match
+
+
+@match_clause.register(Any)
+def _(clause: Any[D], _globals: dict) -> MatchClause[D]:
+    length = clause.length
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        if at + length <= len(of):
+            return Match(at, length)
+        raise MatchFailure(at, clause) from None
+
+    return do_match
+
+
+@match_clause.register(Sequence)
+def _(clause: Sequence[D], _globals: dict) -> MatchClause[D]:
+    head_do_match, *sub_do_matches = (
+        match_clause(sub_clause, _globals) for sub_clause in clause.sub_clauses
+    )
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        match = head_do_match(of, at, memo, rules)
+        for sub_do_match in sub_do_matches:
+            match += sub_do_match(of, match.end, memo, rules)
+        return match
+
+    return do_match
+
+
+@match_clause.register(Entail)
+def _(clause: Entail[D], _globals: dict) -> MatchClause[D]:
+    head_do_match, *sub_do_matches = (
+        match_clause(sub_clause, _globals) for sub_clause in clause.sub_clauses
+    )
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        try:
+            match = head_do_match(of, at, memo, rules)
+            for sub_do_match in sub_do_matches:
+                match += sub_do_match(of, match.end, memo, rules)
+            return match
+        except MatchFailure as mf:
+            raise FatalMatchFailure(mf.at, mf.clause) from mf
+
+    return do_match
+
+
+@match_clause.register(Choice)
+def _(clause: Choice[D], _globals: dict) -> MatchClause[D]:
+    match_sub_clauses = tuple(
+        match_clause(sub_clause, _globals) for sub_clause in clause.sub_clauses
+    )
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        for match_sub_clause in match_sub_clauses:
+            try:
+                return match_sub_clause(of, at, memo, rules)
+            except MatchFailure:
+                pass
+        raise MatchFailure(at, clause)
+
+    return do_match
+
+
+@match_clause.register(Repeat)
+def _(clause: Repeat[D], _globals: dict) -> MatchClause[D]:
+    match_sub_clause = match_clause(clause.sub_clause, _globals)
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        match = match_sub_clause(of, at, memo, rules)
+        while at < match.end < len(of):
+            at = match.end
+            try:
+                match += match_sub_clause(of, at, memo, rules)
+            except MatchFailure:
+                break
+        return match
+
+    return do_match
+
+
+@match_clause.register(Not)
+def _(clause: Not[D], _globals: dict) -> MatchClause[D]:
+    match_sub_clause = match_clause(clause.sub_clause, _globals)
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        try:
+            match_sub_clause(of, at, memo, rules)
+        except MatchFailure:
+            return Match(at, 0)
+        else:
+            raise MatchFailure(at, clause)
+
+    return do_match
+
+
+@match_clause.register(And)
+def _(clause: And[D], _globals: dict) -> MatchClause[D]:
+    match_sub_clause = match_clause(clause.sub_clause, _globals)
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        match_sub_clause(of, at, memo, rules)
+        return Match(at, 0)
+
+    return do_match
+
+
+@match_clause.register(Capture)
+def _(clause: Capture[D], _globals: dict) -> MatchClause[D]:
+    match_sub_clause = match_clause(clause.sub_clause, _globals)
+    name = clause.name
+    variadic = clause.variadic
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        match = match_sub_clause(of, at, memo, rules)
+        results = match.results
+        if variadic:
+            return Match(match.at, match.length, captures=((name, results),))
+        elif not match.results:
+            return Match(
+                match.at, match.length, captures=((name, of[match.at : match.end]),)
+            )
+        elif len(results) == 1:
+            return Match(match.at, match.length, captures=((name, results[0]),))
+        else:
+            raise MatchFailure(at, clause)
+
+    return do_match
+
+
+@match_clause.register(Transform)
+def _(clause: Transform[D], _globals: dict) -> MatchClause[D]:
+    match_sub_clause = match_clause(clause.sub_clause, _globals)
+    py_call = py_transform(clause, _globals)
+
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        match = match_sub_clause(of, at, memo, rules)
+        try:
+            result = py_call(**dict(match.captures))
+        except Exception as err:
+            raise FatalMatchFailure(at, clause) from err
+        return Match(match.at, match.length, results=(result,))
+
+    return do_match
+
+
+@match_clause.register(Reference)
+def _(clause: Reference[D], _globals: dict) -> MatchClause[D]:
+    name = clause.name
+
+    # Adapted from Medeiros et al.
+    def do_match(of: D, at: int, memo: Memo, rules: Rules) -> Match:
+        try:
+            child_match = memo[at, name]
+        except KeyError:
+            # mark this Rule as unmatched ...
+            match = memo[at, name] = None
+            old_end = at - 1
+            # ... then iteratively expand the match
+            while True:
+                try:
+                    new_match = rules[name](of, at, memo, rules)
+                except (MatchFailure, FatalMatchFailure) as mf:
+                    raise type(mf)(at, clause) from mf  # raise for rule to record path
+                if new_match.end > old_end:
+                    match = memo[at, name] = new_match
+                    old_end = new_match.end
+                else:
+                    assert match is not None
+                    return match
+        else:
+            if child_match is None:
+                raise MatchFailure(at, clause)
+            else:
+                return child_match
+
+    return do_match
diff --git a/bootpeg/api.py b/bootpeg/api.py
index 958b3a3..d8c7ba2 100644
--- a/bootpeg/api.py
+++ b/bootpeg/api.py
@@ -1,79 +1,57 @@
-from typing import Mapping, Callable
-from functools import partial
+from typing import Mapping, Callable, Union
+from typing_extensions import Protocol
+from types import MappingProxyType
 
 import importlib_resources
 
 from .utility import grammar_resource
-from .pika.peg import Parser, Clause
-from .pika.act import transform
-from .pika.boot import namespace as pika_namespace
-from .typing import R, TR, D, BootPegParser
+from .apegs.boot import apegs_actions, Parser, Grammar, Clause
+from .typing import R, D
 
 
-def identity(x: TR) -> TR:
-    return x
+bootpeg_actions: Mapping[str, Callable[..., Clause]] = apegs_actions
 
 
-bootpeg_actions: Mapping[str, Callable[..., Clause]] = pika_namespace
-
-
-def bootpeg_post(*args, top="top"):
-    return Parser(top, **{name: clause for name, clause in args[0]})
-
-
-def parse(
-    source: D,
-    parser: Parser[D],
-    actions: Mapping[str, Callable],
-    post: Callable[..., R] = identity,
-    **kwargs,
-) -> R:
-    """Parse a ``source`` with a given ``parser`` and ``actions``"""
-    head, memo = parser.parse(source)
-    assert head.length == len(source), f"matched {head.length} of {len(source)}"
-    pos_captures, kw_captures = transform(head, memo, actions)
-    return post(*pos_captures, **kw_captures, **kwargs)
+class Dialect(Protocol[D]):
+    unparse: Callable[[Union[Parser, Grammar, Clause]], str]
+    parse: Parser[str, Grammar]
 
 
 def create_parser(
     source: str,
-    dialect,
-    actions: Mapping[str, Callable],
-    post: Callable[..., R] = identity,
-    **kwargs,
-) -> BootPegParser[D, R]:
+    dialect: Union[Dialect[str], Parser[str, Grammar]],
+    actions: Mapping[str, Callable] = MappingProxyType({}),
+) -> Parser[D, R]:
     """
     Create a parser from a `source` grammar
 
     :param source: a textual grammar
     :param dialect: the `bootpeg` parser compatible with the grammar
     :param actions: the actions to use for the new parser
-    :param post: the postprocessing action to use for the new parser
-    :param kwargs: any keyword arguments for the `dialect`'s post processing
     """
-    dialect = dialect if not hasattr(dialect, "parse") else dialect.parse
-    parser = dialect(source, **kwargs)
-    return partial(parse, parser=parser, actions=actions, post=post)
+    parser: Parser = getattr(dialect, "parse", dialect)
+    grammar = parser(source)
+    if not isinstance(grammar, Grammar):
+        raise TypeError(
+            f"expected parsing to return a {Grammar.__name__}, got {grammar}"
+        )
+    return grammar.parser(**actions)
 
 
 def import_parser(
     location: str,
     dialect,
-    actions: Mapping[str, Callable],
-    post: Callable[..., R] = identity,
-    **kwargs,
-) -> BootPegParser[D, R]:
+    actions: Mapping[str, Callable] = MappingProxyType({}),
+) -> Parser[D, R]:
     """
     Import a parser from a grammar at a `location`
 
     :param location: a module or module-like name
     :param dialect: the `bootpeg` parser compatible with the grammar
     :param actions: the actions to use for the new parser
-    :param post: the postprocessing action to use for the new parser
-    :param kwargs: any keyword arguments for the `dialect`'s post processing
 
-    The `location` is a dotted module name; it is used to look up the grammar using the
-    resource import machinery.
+    The `location` is a dotted module name; it is used to look up the grammar
+    using the Python resource import machinery.
     Grammar resources have the extension ``.bpeg`` instead of ``.py``.
 
     For example, the location ``"bootpeg.grammars.peg"`` looks for a resource named
@@ -82,6 +60,4 @@ def import_parser(
     resource types such as zip archive members.
     """
     source = importlib_resources.read_text(*grammar_resource(location))
-    dialect = dialect if not hasattr(dialect, "parse") else dialect.parse
-    parser = dialect(source, **kwargs)
-    return partial(parse, parser=parser, actions=actions, post=post)
+    return create_parser(source, dialect, actions)
diff --git a/bootpeg/grammars/bpeg.bpeg b/bootpeg/grammars/bpeg.bpeg
index 9996a58..bfc88de 100644
--- a/bootpeg/grammars/bpeg.bpeg
+++ b/bootpeg/grammars/bpeg.bpeg
@@ -1,64 +1,67 @@
 # Full PEG+Actions parser
 # =======================
-nothing:
-    | "''" | '""' { Nothing() }
-anything:
-    | "." { Anything() }
-end_line:
-    | "
-" {Discard()}
-
-literal:
-    | ('"' :: '"') | ("'" :: "'") { Literal(.*[1:-1]) }
-range:
-    | first=literal spaces "-" ~ spaces last=literal { Range(.first.value, .last.value) }
-delimited:
-    | start=expr spaces "::" ~ spaces stop=expr { Delimited(.start, .stop) }
-
-terminal:
-    | term=(range | nothing | literal | anything) { .term }
+top:
+    | *rules=( rule | ~ end_line )+ !. { Grammar(*rules) }
 
+# repeatedly needed clauses
+end_line:
+    | spaces [ "#" (!\n .)* ] ( \n | !. )
 spaces:
     | " "*
 identifier:
-    | ( ( "a" - "z" ) | ( "A" - "Z" ) | "_" )+ { .* }
+    | ( "a"-"z" | "A"-"Z" | "_" ) ( "a"-"z" | "A"-"Z" | "0"-"9" | "_" )*
+literal:
+    | '"' body=(!'"' .)+ '"' | "'" body=(!"'" .)+ "'" {body}
+
+# rule and action parsing
+rule:
+    | name=identifier ":" end_line ~ *cases=(" " spaces "|" ~ spaces rule_case end_line)+ { Rule(name, Choice(*cases)) }
+    | name=identifier ":" ~ expr=rule_case { Rule(name, expr) }
+rule_case:
+    | expr=expr spaces action=action { Transform(expr, action) }
+    | expr
+action:
+    | "{" body=action_body+ ~ "}" { body }
+action_body:
+    | (!"{" !"}" .)+
+    | "{" action_body ~ "}"
+
+# clause producing expressions
+# the top expression, recursively matching all other expressions
+expr:
+    | choice
 
-optional:
-    | "[" ~ spaces expr=expr spaces "]" { either(.expr, Nothing()) }
-group:
-    | "(" ~ spaces expr=expr spaces ")" { .expr }
 choice:
-    | try=expr spaces "|" ~ spaces else=expr { either(.try, .else) }
+    | head=sequence *tail=(spaces "|" ~ spaces sequence)+ { Choice(head, *tail) }
+    | sequence
+
 sequence:
-    | head=expr spaces tail=expr { chain(.head, .tail) }
-repeat:
-    | expr=expr spaces "+" { Repeat(.expr) }
-    | expr=expr spaces "*" { either(Repeat(.expr), Nothing()) }
+    | *head=(capture spaces)+ "~" ~ *tail=(spaces capture)+ { Sequence(*head, Entail(*tail)) }
+    | head=capture *tail=(spaces capture)+ { Sequence(head, *tail) }
+    | "~" ~ *tail=(spaces capture)+ { Entail(*tail) }
+    | capture
+
 capture:
-    | name=identifier spaces "=" ~ spaces expr=(reference | group) { Capture(.name, .expr) }
-reference:
-    | name=identifier { Reference(.name) }
-reject:
-    | "!" ~ spaces expr=expr { Not(.expr) }
-require:
-    | "&" ~ spaces expr=expr { And(.expr) }
-commit:
-    | "~" ~ spaces expr=expr { require(.expr) }
-expr:
-    | expr=(choice | require | sequence | repeat | delimited | capture | reference | group | optional | reject | commit | terminal) { .expr }
+    | "*" ~ name=identifier "=" expr=repeat { Capture(expr, name, variadic=True) }
+    | name=identifier "=" ~ expr=repeat { Capture(expr, name, variadic=False) }
+    | repeat
 
-rule:
-    | "|" spaces expr=expr spaces action=("{" :: "}") { Rule(.expr, Action(.action[1:-1])) }
-    | "|" spaces expr=expr { Rule(.expr, Action(".*[0]")) }
+repeat:
+    | expr=prefix "+" { Repeat(expr) }
+    | expr=prefix "*" { Choice(Repeat(expr), Empty()) }
+    | prefix
 
-comment:
-    | "#" :: end_line { Discard() }
-blank:
-    | spaces end_line { Discard() }
-define:
-    | name=identifier ":" ~ spaces end_line rules=rules { (.name, .rules) }
-rules:
-    | " " spaces try=rule spaces end_line else=rules { either(.try, .else) }
-    | " " spaces rule=rule spaces end_line { .rule }
-top:
-    | ( define | comment | blank )+ { .* }
+prefix:
+    | "!" spaces expr=prefix { Not(expr) }
+    | "&" spaces expr=prefix { And(expr) }
+    | "(" spaces ~ expr spaces ")"
+    | "[" spaces ~ expr=expr spaces "]" { Choice(expr, Empty()) }
+    | atom
+
+atom:
+    | "''" | '""' { Empty() }
+    | "." { Any(1) }
+    | "\n" { Value("\n") }
+    | lower=literal spaces "-" ~ spaces upper=literal { Range(lower, upper) }
+    | literal=literal { Value(literal) }
+    | name=identifier { Reference(name) }
diff --git a/bootpeg/grammars/bpeg.py b/bootpeg/grammars/bpeg.py
index f2de621..fa9b52d 100644
--- a/bootpeg/grammars/bpeg.py
+++ b/bootpeg/grammars/bpeg.py
@@ -4,111 +4,144 @@
 from typing import Union
 from functools import singledispatch
 
-from ..pika.peg import (
-    Clause,
-    Nothing,
-    Anything,
-    Literal,
+from ..apegs.boot import (
+    Value,
+    Range,
+    Any,
+    Empty,
     Sequence,
     Choice,
     Repeat,
-    Not,
     And,
+    Not,
+    Entail,
+    Capture,
+    Transform,
     Reference,
+    Rule,
+    Clause,
     Parser,
+    Grammar,
+    bpeg_parser,
 )
-from ..pika.act import Capture, Rule
-from ..pika.front import Range, Delimited
-from ..pika.boot import bootpeg, boot
-from ..api import bootpeg_actions, bootpeg_post, import_parser
-from ..typing import BootPegParser
+from ..api import bootpeg_actions, import_parser
+
+
+precedence = {
+    clause: prec
+    for prec, clauses in enumerate(
+        (
+            [Value, Range, Any, Empty, Reference],
+            [Not, And],
+            [Repeat, Capture],
+            [Sequence, Entail],
+            [Choice, Transform],
+        )
+    )
+    for clause in clauses
+}
+
+
+def _wrapped(clause: Clause, parent: Clause) -> str:
+    return (
+        f"({unparse(clause)})"
+        if precedence[type(parent)] < precedence[type(clause)]
+        else unparse(clause)
+    )
 
 
 @singledispatch
-def unparse(clause: Union[Clause, Parser], top=True) -> str:
+def unparse(clause: Union[Clause, Parser, Grammar]) -> str:
     """Format a ``clause`` according to bootpeg standard grammar"""
     raise NotImplementedError(f"Cannot unparse {clause!r} as bpeg")
 
 
-@unparse.register(Nothing)
-def unparse_nothing(clause: Nothing, top=True) -> str:
+@unparse.register(Parser)
+@unparse.register(Grammar)
+def unparse_grammar(clause: Union[Parser, Grammar]) -> str:
+    return "\n\n".join(unparse(rule) for rule in clause.rules)
+
+
+@unparse.register(Value)
+def unparse_literal(clause: Value) -> str:
+    return repr(clause.value)
+
+
+@unparse.register(Range)
+def unparse_range(clause: Range) -> str:
+    return f"{clause.lower!r}-{clause.upper!r}"
+
+
+@unparse.register(Empty)
+def unparse_empty(clause: Empty) -> str:
     return '""'
 
 
-@unparse.register(Anything)
-def unparse_anything(clause: Anything, top=True) -> str:
+@unparse.register(Any)
+def unparse_any(clause: Any) -> str:
     return "." * clause.length
 
 
-@unparse.register(Literal)
-def unparse_literal(clause: Literal, top=True) -> str:
-    return repr(clause.value)
+@unparse.register(Reference)
+def unparse_reference(clause: Reference) -> str:
+    return clause.name
 
 
 @unparse.register(Sequence)
-def unparse_sequence(clause: Sequence, top=True) -> str:
-    children = " ".join(
-        unparse(sub_clause, top=False) for sub_clause in clause.sub_clauses
+def unparse_sequence(clause: Sequence) -> str:
+    return " ".join(_wrapped(sub_clause, clause) for sub_clause in clause.sub_clauses)
+
+
+@unparse.register(Entail)
+def unparse_entail(clause: Entail) -> str:
+    return "~ " + " ".join(
+        _wrapped(sub_clause, clause) for sub_clause in clause.sub_clauses
     )
-    return f"({children})" if not top else children
 
 
 @unparse.register(Choice)
-def unparse_choice(clause: Choice, top=True) -> str:
-    children = " | ".join(
-        unparse(sub_clause, top=False) for sub_clause in clause.sub_clauses
-    )
-    return f"({children})" if not top else children
+def unparse_choice(clause: Choice) -> str:
+    return " | ".join(_wrapped(sub_clause, clause) for sub_clause in clause.sub_clauses)
 
 
 @unparse.register(Repeat)
-def unparse_repeat(clause: Repeat, top=True) -> str:
-    return unparse(clause.sub_clauses[0], top=False) + "+"
+def unparse_repeat(clause: Repeat) -> str:
+    return _wrapped(clause.sub_clause, clause) + "+"
 
 
 @unparse.register(Not)
-def unparse_not(clause: Not, top=True) -> str:
-    return "!" + unparse(clause.sub_clauses[0], top=False)
+def unparse_not(clause: Not) -> str:
+    return "!" + _wrapped(clause.sub_clause, clause)
 
 
 @unparse.register(And)
-def unparse_and(clause: And, top=True) -> str:
-    return "&" + unparse(clause.sub_clauses[0], top=False)
-
-
-@unparse.register(Reference)
-def unparse_reference(clause: Reference, top=True) -> str:
-    return clause.target
+def unparse_and(clause: And) -> str:
+    return "&" + _wrapped(clause.sub_clause, clause)
 
 
 @unparse.register(Capture)
-def unparse_capture(clause: Capture, top=True) -> str:
-    return f"{clause.name}={unparse(clause.sub_clauses[0], top=False)}"
+def unparse_capture(clause: Capture) -> str:
+    var = "*" if clause.variadic else ""
+    return f"{var}{clause.name}={_wrapped(clause.sub_clause, clause)}"
 
 
-@unparse.register(Rule)
-def unparse_rule(clause: Rule, top=True) -> str:
-    return f"| {unparse(clause.sub_clauses[0])} {{{clause.action.literal}}}"
-
-
-@unparse.register(Range)
-def unparse_range(clause: Range, top=True) -> str:
-    return f"{clause.first!r} - {clause.last!r}"
+@unparse.register(Transform)
+def unparse_transform(clause: Transform) -> str:
+    return f"{unparse(clause.sub_clause)} {{{clause.action}}}"
 
 
-@unparse.register(Delimited)
-def unparse_delimited(clause: Delimited, top=True) -> str:
-    first, last = clause.sub_clauses
-    return f"{unparse(first, top=False)} :: {unparse(last, top=False)}"
-
-
-def boot_dialect(source):
-    return boot(bootpeg(), source)
+@unparse.register(Rule)
+def unparse_rule(clause: Rule) -> str:
+    sub_clause = clause.sub_clause
+    if isinstance(sub_clause, Choice):
+        body = "\n".join(f"    | {unparse(case)}" for case in sub_clause.sub_clauses)
+    else:
+        body = f"    | {unparse(sub_clause)}"
+    return f"{clause.name}:\n{body}"
 
 
-parse: BootPegParser[str, BootPegParser] = import_parser(
+parse: Parser[str, Grammar] = import_parser(
     __name__,
-    dialect=boot_dialect,
+    dialect=bpeg_parser,
     actions=bootpeg_actions,
-    post=bootpeg_post,
 )
diff --git a/bootpeg/grammars/peg.bpeg b/bootpeg/grammars/peg.bpeg
index c1dff6d..b4dfa2f 100644
--- a/bootpeg/grammars/peg.bpeg
+++ b/bootpeg/grammars/peg.bpeg
@@ -2,78 +2,80 @@
 # Adapted from "Parsing Expression Grammars: A Recognition-Based Syntactic Foundation"
 #               by Bryan Ford
 # ===============================
-# terminals
-nothing:
-    | "''" | '""' { Nothing() }
-anything:
-    | "." { Anything() }
+top:
+    | *rules=(definition | spacing)+ ~!. { Grammar(*rules) }
 
+spacing:
+    | ((" " | "	")* [ "#" (!\n .)* ] [\n | !.])+
+identifier:
+    | ( "a"-"z" | "A"-"Z" | "_" ) ( "a"-"z" | "A"-"Z" | "0"-"9" | "_" )*
 literal:
-    | ('"' :: '"') | ("'" :: "'") { Literal(unescape(.*[1:-1])) }
-class:
-    | "[" expr=range "]" { .expr }
-range:
-    | head=range tail=range { either(.head, .tail) }
-    | first=char "-" last=char { Range(.first, .last) }
-    | char=char { Literal(.char) }
-char:
-    | ( "\'" | '\"' | "\[" | "\]" | "\\" ) { .*[1] }
-    | ( "\n" | "\r" | "\t" ) { unescape(.*) }
-    | "\" ("0" - "2") ("0" - "7") ("0" - "7") { unescape(.*) }
-    | "\" ("0" - "7") ["0" - "7"] { unescape(.*) }
-    | "\u" hexdigit hexdigit hexdigit hexdigit { unescape(.*) }
-    | "\U" hexdigit hexdigit hexdigit hexdigit hexdigit hexdigit hexdigit hexdigit { unescape(.*) }
-    | (! "\") (! "[") (! "]") . { .* }
-hexdigit:
-    | ("0" - "9") | ("a" - "f") | ("A" - "F") { Discard() }
+    | '"' body=(!'"' .)+ '"' | "'" body=(!"'" .)+ "'" {body}
 
-terminal:
-    | term=(class | nothing | literal | anything) { .term }
+# definitions
+definition:
+    | name=identifier spacing ('<-' | '←') spacing ["/" spacing] ~ expr=expr { Rule(name, expr) }
 
-# separators
-end_line:
-    | "
-"
-comment:
-    | "#" :: end_line
-spacing:
-    | (" " | "	" | end_line | comment)* { Discard() }
+# match clauses
+expr:
+    | choice
 
-identifier:
-    | ( ( "a" - "z" ) | ( "A" - "Z" ) | "_" )+ { .* }
+choice:
+    | head=transform *tail=(spacing "/" ~ spacing transform)+ { Choice(head, *tail) }
+    | transform
+
+transform:
+    | expr=sequence spacing action=action { Transform(expr, action) }
+    | sequence
+action:
+    | "{" body=action_body+ ~ "}" { body }
+action_body:
+    | (!"{" !"}" .)+
+    | "{" action_body ~ "}"
 
-# match clauses
-expression:
-    | try=sequence spacing "/" spacing else=expression { either(.try, .else) }
-    | expr=sequence spacing { .expr }
 sequence:
-    | head=prefixed spacing tail=sequence { chain(.head, .tail) }
-    | expr=prefixed { .expr }
-prefixed:
-    | "&" expr=suffixed { And(.expr) }
-    | "!" expr=suffixed { Not(.expr) }
-    | expr=suffixed { .expr }
-suffixed:
-    | expr=primary "?" { either(.expr, Nothing()) }
-    | expr=primary "*" { either(Repeat(.expr), Nothing()) }
-    | expr=primary "+" { Repeat(.expr) }
-    | expr=primary { .expr }
-# exclude "<-" to avoid matching a <- b c <- d as a <- (b c)
-primary:
-    | name=identifier !(spacing ('<-' | '←')) { Reference(.name) }
-    | '(' spacing expr=expression spacing ')' { .expr }
-    | term=terminal { .term }
+    | *head=(capture spacing)+ "~" ~ *tail=(spacing capture)+ { Sequence(*head, Entail(*tail)) }
+    | head=capture *tail=(spacing capture)+ { Sequence(head, *tail) }
+    | "~" ~ *tail=(spacing capture)+ { Entail(*tail) }
+    | capture
 
-# definitions
-rule:
-    | expr=expression spacing action=("{" :: "}") { Rule(.expr, Action(.action[1:-1])) }
-    | expr=expression { Rule(.expr, Action(".*[0]")) }
-rules:
-    | try=rule spacing "/" spacing else=rules { either(.try, .else) }
-    | rule=rule { .rule }
+capture:
+    | "*" ~ name=identifier "=" expr=repeat { Capture(expr, name, variadic=True) }
+    | name=identifier "=" ~ expr=repeat { Capture(expr, name, variadic=False) }
+    | repeat
 
-definition:
-    | name=identifier spacing ('<-' | '←') spacing ["/" spacing] rules=rules { (.name, .rules) }
+repeat:
+    | expr=prefix "+" { Repeat(expr) }
+    | expr=prefix "*" { Choice(Repeat(expr), Empty()) }
+    | expr=prefix "?" { Choice(expr, Empty()) }
+    | prefix
 
-top:
-    | (spacing definition)+ spacing { .* }
+prefix:
+    | "!" spacing expr=prefix { Not(expr) }
+    | "&" spacing expr=prefix { And(expr) }
+    | "(" spacing ~ expr spacing ")"
+    | atom
+
+# exclude "identifier <-" to avoid matching a <- b c <- d as a <- (b c)
+atom:
+    | "''" | '""' { Empty() }
+    | "." { Any(1) }
+    | class
+    | literal=literal { Value(unescape(literal)) }
+    | name=identifier !(spacing ('<-' | '←')) { Reference(name) }
+
+class:
+    | "[" range "]"
+    | "[" ~ *ranges=range+ "]" { Choice(*ranges) }
+range:
+    | first=char "-" last=char { Range(first, last) }
+    | char=char { Value(char) }
+char:
+    | escaped=( "\'" | '\"' | "\[" | "\]" | "\\" ) { escaped[-1:] }
+    | space=( "\n" | "\r" | "\t" ) { unescape(space) }
+    | octal=("\" "0"-"2" "0"-"7" "0"-"7" | "\" "0"-"7" ["0"-"7"]) { unescape(octal) }
+    | u16=("\u" hexdigit hexdigit hexdigit hexdigit) { unescape(u16) }
+    | u32=("\U" hexdigit hexdigit hexdigit hexdigit hexdigit hexdigit hexdigit hexdigit) { unescape(u32) }
+    | !"\" !"[" !"]" .
+hexdigit:
+    | "0"-"9" | "a"-"f" | "A"-"F"
diff --git a/bootpeg/grammars/peg.py b/bootpeg/grammars/peg.py
index 74e24cd..6777442 100644
--- a/bootpeg/grammars/peg.py
+++ b/bootpeg/grammars/peg.py
@@ -1,109 +1,151 @@
 from typing import Union
 from functools import singledispatch
 
-from ..pika.peg import (
-    Clause,
-    Nothing,
-    Anything,
-    Literal,
+from ..apegs.boot import (
+    Value,
+    Range,
+    Any,
+    Empty,
     Sequence,
     Choice,
     Repeat,
-    Not,
     And,
+    Not,
+    Entail,
+    Capture,
+    Transform,
     Reference,
+    Rule,
+    Clause,
     Parser,
+    Grammar,
 )
-from ..pika.act import Capture, Rule
-from ..pika.front import Range, Delimited
-from ..api import bootpeg_actions, bootpeg_post, import_parser
-from ..typing import BootPegParser
+from ..api import bootpeg_actions, import_parser
 from . import bpeg
 
 
+precedence = {
+    clause: prec
+    for prec, clauses in enumerate(
+        (
+            [Value, Range, Any, Empty, Reference],
+            [Not, And],
+            [Repeat, Capture],
+            [Sequence, Entail],
+            [Transform],
+            [Choice],
+        )
+    )
+    for clause in clauses
+}
+
+
+def _wrapped(clause: Clause, parent: Clause) -> str:
+    return (
+        f"({unparse(clause)})"
+        if precedence[type(parent)] < precedence[type(clause)]
+        else unparse(clause)
+    )
+
+
 @singledispatch
-def unparse(clause: Union[Clause, Parser], top=True) -> str:
-    """Format a ``clause`` according to bootpeg standard grammar"""
+def unparse(clause: Union[Clause, Parser, Grammar]) -> str:
+    """Format a ``clause`` according to PEG"""
     raise NotImplementedError(f"Cannot unparse {clause!r} as bpeg")
 
 
-@unparse.register(Nothing)
-def unparse_nothing(clause: Nothing, top=True) -> str:
+@unparse.register(Parser)
+@unparse.register(Grammar)
+def unparse_grammar(clause: Union[Parser, Grammar]) -> str:
+    return "\n\n".join(unparse(rule) for rule in clause.rules)
+
+
+@unparse.register(Value)
+def unparse_literal(clause: Value) -> str:
+    return repr(clause.value)
+
+
+@unparse.register(Range)
+def unparse_range(clause: Range) -> str:
+    return f"[{repr(clause.lower)[1:-1]}-{repr(clause.upper)[1:-1]}]"
+
+
+@unparse.register(Empty)
+def unparse_empty(clause: Empty) -> str:
     return '""'
 
 
-@unparse.register(Anything)
-def unparse_anything(clause: Anything, top=True) -> str:
+@unparse.register(Any)
+def unparse_any(clause: Any) -> str:
     return "." * clause.length
 
 
-@unparse.register(Literal)
-def unparse_literal(clause: Literal, top=True) -> str:
-    return repr(clause.value)
+@unparse.register(Reference)
+def unparse_reference(clause: Reference) -> str:
+    return clause.name
 
 
 @unparse.register(Sequence)
-def unparse_sequence(clause: Sequence, top=True) -> str:
-    children = " ".join(
-        unparse(sub_clause, top=False) for sub_clause in clause.sub_clauses
+def unparse_sequence(clause: Sequence) -> str:
+    return " ".join(_wrapped(sub_clause, clause) for sub_clause in clause.sub_clauses)
+
+
+@unparse.register(Entail)
+def unparse_entail(clause: Entail) -> str:
+    return "~ " + " ".join(
+        _wrapped(sub_clause, clause) for sub_clause in clause.sub_clauses
     )
-    return f"({children})" if not top else children
 
 
 @unparse.register(Choice)
-def unparse_choice(clause: Choice, top=True) -> str:
-    children = " / ".join(
-        unparse(sub_clause, top=False) for sub_clause in clause.sub_clauses
-    )
-    return f"({children})" if not top else children
+def unparse_choice(clause: Choice) -> str:
+    return " / ".join(_wrapped(sub_clause, clause) for sub_clause in clause.sub_clauses)
 
 
 @unparse.register(Repeat)
-def unparse_repeat(clause: Repeat, top=True) -> str:
-    return unparse(clause.sub_clauses[0], top=False) + "+"
+def unparse_repeat(clause: Repeat) -> str:
+    return _wrapped(clause.sub_clause, clause) + "+"
 
 
 @unparse.register(Not)
-def unparse_not(clause: Not, top=True) -> str:
-    return "!" + unparse(clause.sub_clauses[0], top=False)
+def unparse_not(clause: Not) -> str:
+    return "!" + _wrapped(clause.sub_clause, clause)
 
 
 @unparse.register(And)
-def unparse_and(clause: And, top=True) -> str:
-    return "&" + unparse(clause.sub_clauses[0], top=False)
-
-
-@unparse.register(Reference)
-def unparse_reference(clause: Reference, top=True) -> str:
-    return clause.target
+def unparse_and(clause: And) -> str:
+    return "&" + _wrapped(clause.sub_clause, clause)
 
 
 @unparse.register(Capture)
-def unparse_capture(clause: Capture, top=True) -> str:
-    return f"{clause.name}={unparse(clause.sub_clauses[0], top=False)}"
+def unparse_capture(clause: Capture) -> str:
+    var = "*" if clause.variadic else ""
+    return f"{var}{clause.name}={_wrapped(clause.sub_clause, clause)}"
 
 
-@unparse.register(Rule)
-def unparse_rule(clause: Rule, top=True) -> str:
-    return f"{unparse(clause.sub_clauses[0])} {{{clause.action.literal}}}"
+@unparse.register(Transform)
+def unparse_transform(clause: Transform) -> str:
+    return f"{unparse(clause.sub_clause)} {{{clause.action}}}"
 
 
-@unparse.register(Range)
-def unparse_range(clause: Range, top=True) -> str:
-    assert len(clause.first) == len(clause.last) == 1, "range borders must be chars"
-    return f"[{clause.first}-{clause.last}]"
+@unparse.register(Rule)
+def unparse_rule(clause: Rule) -> str:
+    sub_clause = clause.sub_clause
+    if isinstance(sub_clause, Choice):
+        body = "\n".join(f"    / {unparse(case)}" for case in sub_clause.sub_clauses)
+        return f"{clause.name} <-\n{body}"
+    else:
+        return f"{clause.name} <- {unparse(sub_clause)}"
 
 
-@unparse.register(Delimited)
-def unparse_delimited(clause: Delimited, top=True) -> str:
-    first, last = (unparse(c, top=False) for c in clause.sub_clauses)
-    children = f"{first} (!{last} .)* {last}"
-    return f"({children})" if not top else children
+def unescape(literal: str) -> str:
+    """Evaluate Python escape sequences in a string, e.g. ``r"\n"`` to ``"\n"``"""
+    # see https://stackoverflow.com/a/57192592/5349916
+    return literal.encode("latin-1", "backslashreplace").decode("unicode-escape")
 
 
-parse: BootPegParser[str, BootPegParser] = import_parser(
+parse: Parser[str, Grammar] = import_parser(
     __name__,
     dialect=bpeg,
-    actions=bootpeg_actions,
-    post=bootpeg_post,
+    actions={**bootpeg_actions, "unescape": unescape},
 )
diff --git a/bootpeg/pika/__init__.py b/bootpeg/pika/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/bootpeg/pika/act.py b/bootpeg/pika/act.py
deleted file mode 100644
index e5bb7de..0000000
--- a/bootpeg/pika/act.py
+++ /dev/null
@@ -1,354 +0,0 @@
-"""
-Pika bottom-up Peg parser extension to transform parsed source
-"""
-from typing import TypeVar, Any, Dict, Tuple, Mapping, Union, NamedTuple, Set
-import re
-
-from .peg import Clause, D, Match, MemoTable, MemoKey, Literal, nested_str, Reference
-from ..utility import mono, cache_hash
-
-
-#: Parser result: The output type for parsing, such as (str, ...)
-R = TypeVar("R", contravariant=True)
-
-
-class Debug(Clause[str]):
-    def __init__(self, sub_clause, name: str = None):
-        self.sub_clauses = (sub_clause,)
-        self.name = name or str(sub_clause)
-
-    @property
-    def maybe_zero(self):
-        return self.sub_clauses[0].maybe_zero
-
-    def match(self, source: str, at: int, memo: MemoTable):
-        try:
-            parent_match = memo[MemoKey(at, self.sub_clauses[0])]
-        except KeyError:
-            return None
-        else:
-            print("match", self.name, "at", at, ":", parent_match.length)
-            print("'", mono(source[at : at + parent_match.length]), "'", sep="")
-            return parent_match
-
-    def __eq__(self, other):
-        return isinstance(other, Literal) and self.sub_clauses == other.sub_clauses
-
-    def __hash__(self):
-        return hash(self.sub_clauses)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.sub_clauses[0]!r})"
-
-    def __str__(self):
-        return f":{self.name}:"
-
-
-class Capture(Clause[D]):
-    """Capture the result of matching a clause by name for an :py:class:`~.Action`"""
-
-    __slots__ = ("name", "sub_clauses", "_hash")
-
-    def __init__(self, name: str, sub_clause: Clause[D]):
-        self.name = name
-        self.sub_clauses = (sub_clause,)
-
-    @property
-    def maybe_zero(self):
-        return self.sub_clauses[0].maybe_zero
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        parent_match = memo[MemoKey(at, self.sub_clauses[0])]
-        return Match(parent_match.length, (parent_match,), at, self)
-
-    def __eq__(self, other):
-        return (
-            isinstance(other, Capture)
-            and self.name == other.name
-            and self.sub_clauses == other.sub_clauses
-        )
-
-    @cache_hash
-    def __hash__(self):
-        return hash((self.name, self.sub_clauses))
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.name!r}, {self.sub_clauses[0]!r})"
-
-    def __str__(self):
-        return f"{self.name}={nested_str(self.sub_clauses[0])}"
-
-
-def captures(head: Clause):
-    if isinstance(head, Capture):
-        yield head
-    elif isinstance(head, Reference):
-        return
-    else:
-        for clause in head.sub_clauses:
-            yield from captures(clause)
-
-
-class ActionCaptureError(TypeError):
-    def __init__(self, missing, extra, rule):
-        self.missing = missing
-        self.extra = extra
-        self.rule = rule
-        msg = f"extra {', '.join(map(repr, extra))}" if extra else ""
-        msg += " and " if extra and missing else ""
-        msg += f"missing {', '.join(map(repr, missing))}" if missing else ""
-        super().__init__(f"{msg} captures: {self.rule}")
-
-
-class Rule(Clause[D]):
-    __slots__ = ("sub_clauses", "action", "_hash")
-
-    def __init__(self, sub_clause: Clause[D], action: "Action"):
-        self.sub_clauses = (sub_clause,)
-        self.action = action
-        self._verify_captures()
-
-    def _verify_captures(self):
-        captured_names = {capture.name for capture in captures(self.sub_clauses[0])}
-        if captured_names ^ self.action.parameters:
-            additional = captured_names - self.action.parameters
-            missing = self.action.parameters - captured_names
-            raise ActionCaptureError(missing=missing, extra=additional, rule=self)
-
-    @property
-    def maybe_zero(self):
-        return self.sub_clauses[0].maybe_zero
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        parent_match = memo[MemoKey(at, self.sub_clauses[0])]
-        return Match(parent_match.length, (parent_match,), at, self)
-
-    def __eq__(self, other):
-        return (
-            isinstance(other, Rule)
-            and self.action == other.action
-            and self.sub_clauses == other.sub_clauses
-        )
-
-    @cache_hash
-    def __hash__(self):
-        return hash((self.action, self.sub_clauses))
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.sub_clauses[0]!r}, {self.action!r})"
-
-    def __str__(self):
-        return f"| {self.sub_clauses[0]} {self.action}"
-
-
-class Discard:
-    def __str__(self):
-        return "∅"
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}()"
-
-
-class Action:
-    __slots__ = ("literal", "_py_names", "_py_source", "_py_code")
-    # TODO: Define these via a PEG parser
-    unpack = re.compile(r"\.\*")
-    named = re.compile(r"(^|[ (])\.([a-zA-Z]+)")
-    mangle = "__pika_act_"
-
-    def __init__(self, literal: str):
-        self.literal = literal.strip()
-        self._py_names = {match.group(2) for match in self.named.finditer(literal)}
-        self._py_source = self._encode(self.literal)
-        self._py_code = compile(self._py_source, self._py_source, "eval")
-
-    @property
-    def parameters(self) -> Set[str]:
-        """The parameter names used by the action"""
-        return self._py_names
-
-    def __call__(self, __namespace, *args, **kwargs):
-        return eval(self._py_code, __namespace)(*args, **kwargs)
-
-    def _encode(self, literal):
-        names = [f"{self.mangle}{name}" for name in self._py_names]
-        body = self.named.sub(
-            rf"\1 {self.mangle}\2", self.unpack.sub(rf" {self.mangle}all", literal)
-        )
-        return f'lambda {self.mangle}all, {", ".join(names)}: {body}'
-
-    def __eq__(self, other):
-        return isinstance(other, Action) and self.literal == other.literal
-
-    def __hash__(self):
-        return hash(self.literal)
-
-    def __str__(self):
-        return f"{{{self.literal}}}"
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.literal!r})"
-
-
-class Commit(Clause[D]):
-    """
-    The commitment to a clause, requiring a match of the sub_clause
-
-    This clause always matches, even if the sub_clause does not.
-    As a result, it "cuts" away alternatives of *not* matching the sub_clause.
-
-    Since Pika matches spuriously and can accumulate several failures,
-    a ``Cut`` does not raise an error during parsing.
-    Any :py:meth:`~.failed` match should be reported after parsing.
-    """
-
-    __slots__ = ("sub_clauses", "_hash")
-
-    def __init__(self, sub_clause: Clause[D]):
-        self.sub_clauses = (sub_clause,)
-
-    @property
-    def maybe_zero(self):
-        return True
-
-    # The difference between a successful and failed match
-    # is that we do/don't have a parent match.
-    # A match length may be 0 in either case, if the parent
-    # matched 0 length or not at all.
-    def match(self, source: D, at: int, memo: MemoTable):
-        # While `Commit` handles cases in which the child is not matched,
-        # Pika never tries to match `Commit` without the child matching.
-        # An "empty" match is created by the memo as needed.
-        parent_match = memo[MemoKey(at, self.sub_clauses[0])]
-        return Match(parent_match.length, (parent_match,), at, self)
-
-    @classmethod
-    def failed(cls, match: Match) -> bool:
-        """Check whether the given ``match`` only succeeded due to the ``Cut``."""
-        assert isinstance(match.clause, cls), f"a {cls.__name__} match is required"
-        return not match.sub_matches and not match.clause.sub_clauses[0].maybe_zero
-
-    def __eq__(self, other):
-        return isinstance(other, Commit) and self.sub_clauses == other.sub_clauses
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self.sub_clauses)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.sub_clauses[0]!r})"
-
-    def __str__(self):
-        return f"~{self.sub_clauses[0]}"
-
-
-class CommitFailure(NamedTuple):
-    """Information on a single failed :py:class:`~.Commit` match"""
-
-    position: int
-    commit: Commit
-
-
-class NamedFailure(NamedTuple):
-    """Information on all failures of a :py:class:`~.Reference` match"""
-
-    name: str
-    failures: Tuple[CommitFailure, ...]
-
-
-def leaves(failure):
-    if isinstance(failure, CommitFailure):
-        yield failure
-    elif isinstance(failure, NamedFailure):
-        for child in failure.failures:
-            yield from leaves(child)
-    else:
-        raise NotImplementedError
-
-
-class CapturedParseFailure(Exception):
-    """Parsing captured match failures"""
-
-    def __init__(self, *failures: Union[NamedFailure, CommitFailure]):
-        self.failures = failures
-        leave_failures = sorted(
-            {leave for failure in failures for leave in leaves(failure)},
-            key=lambda failure: failure.position,
-        )
-        leave_reports = (
-            f"{str(failure.commit.sub_clauses[0])!r}@{failure.position}"
-            for failure in leave_failures
-        )
-        super().__init__(f'failed expected parse of {", ".join(leave_reports)}')
-
-    @property
-    def positions(self):
-        return {
-            leave.position for failure in self.failures for leave in leaves(failure)
-        }
-
-
-class TransformFailure(Exception):
-    def __init__(self, clause, matches, captures, exc: Exception):
-        super().__init__(f"failed to transform {clause}: {exc}")
-        self.clause = clause
-        self.matches = matches
-        self.captures = captures
-        self.exc = exc
-
-
-def transform(head: Match, memo: MemoTable, namespace: Mapping[str, Any]):
-    matches, captures, failures = postorder_transform(head, memo.source, namespace)
-    if not failures:
-        return matches, captures
-    else:
-        raise CapturedParseFailure(*failures)
-
-
-# TODO: Use trampoline/coroutines for infinite depth
-def postorder_transform(
-    match: Match, source: D, namespace: Mapping[str, Any]
-) -> Tuple[Tuple, Dict[str, Any], Tuple]:
-    matches: Tuple = ()
-    captures: Dict[str, Any] = {}
-    failures: Tuple = ()
-    for sub_match in match.sub_matches:
-        sub_matches, sub_captures, sub_failures = postorder_transform(
-            sub_match, source, namespace
-        )
-        matches += sub_matches
-        captures.update(sub_captures)
-        failures += sub_failures
-    position, clause = match.position, match.clause
-    if isinstance(clause, Commit):
-        if Commit.failed(match):
-            failures = (*failures, CommitFailure(position, clause))
-    elif isinstance(clause, Reference):
-        new_failures: Tuple[CommitFailure, ...] = tuple(
-            failure for failure in failures if not isinstance(failure, NamedFailure)
-        )
-        if new_failures:
-            failures = (
-                NamedFailure(clause.target, new_failures),
-                *(failure for failure in failures if isinstance(failure, NamedFailure)),
-            )
-    if failures:
-        return (), {}, failures
-    elif isinstance(clause, Capture):
-        assert len(matches) <= 1, "Captured rule must provide no more than one value"
-        captures[Action.mangle + clause.name] = (
-            matches[0] if matches else source[position : position + match.length]
-        )
-        return (), captures, failures
-    elif isinstance(clause, Rule):
-        rule_matches: Union[Tuple[Any, ...], D] = (
-            matches if matches else source[position : position + match.length]
-        )
-        try:
-            result = clause.action(namespace, rule_matches, **captures)
-        except ActionCaptureError:
-            raise
-        except Exception as exc:
-            raise TransformFailure(clause, matches, captures, exc)
-        return (result,) if not isinstance(result, Discard) else (), {}, failures
-    return matches, captures, failures
diff --git a/bootpeg/pika/boot.bpeg b/bootpeg/pika/boot.bpeg
deleted file mode 100644
index 4396b7f..0000000
--- a/bootpeg/pika/boot.bpeg
+++ /dev/null
@@ -1,60 +0,0 @@
-# minimal EBNF+Actions PEG parser
-# manually maintained, should be in sync with boot.py
-spaces:
-    | " "+ | "" { Discard() }
-nothing:
-    | "''" | '""' { Nothing() }
-anything:
-    | "." { Anything() }
-end_line:
-    | "
-" {Discard()}
-identifier:
-    | ( "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" | "_" )+ { .* }
-
-literal:
-    | ('"' ( (!'"') . )+ '"') | ("'" ( (!"'") . )+ "'") { Literal(.*[1:-1]) }
-range:
-    | first=literal spaces "-" spaces last=literal { Range(.first.value, .last.value) }
-delimited:
-    | start=expr spaces "::" spaces stop=expr { Delimited(.start, .stop) }
-
-optional:
-    | "[" spaces expr=expr spaces "]" { either(.expr, Nothing()) }
-group:
-    | "(" spaces expr=expr spaces ")" { .expr }
-choice:
-    | try=expr spaces "|" spaces else=expr { either(.try, .else) }
-sequence:
-    | head=expr spaces tail=expr { chain(.head, .tail) }
-repeat:
-    | expr=expr spaces "+" { Repeat(.expr) }
-    | expr=expr spaces "*" { either(Repeat(.expr), Nothing()) }
-capture:
-    | name=identifier spaces "=" spaces expr=(reference | group) { Capture(.name, .expr) }
-reference:
-    | name=identifier { Reference(.name) }
-reject:
-    | "!" spaces expr=expr { Not(.expr) }
-require:
-    | "&" spaces expr=expr { And(.expr) }
-commit:
-    | "~" spaces expr=expr { require(.expr) }
-expr:
-    | expr=(choice | commit | sequence | repeat | delimited | capture | reference | group | optional | reject | require | range | literal | anything | nothing) { .expr }
-
-rule:
-    | "|" spaces expr=expr spaces "{" body=(((!"}") .)+) "}" { Rule(.expr, Action(.body)) }
-    | "|" spaces expr=expr { Rule(.expr, Action("Discard()")) }
-
-comment:
-    | "#" ((!end_line) .)+ end_line { Discard() }
-blank:
-    | spaces end_line { Discard() }
-define:
-    | name=identifier ":" spaces end_line rules=rules { (.name, .rules) }
-rules:
-    | " " spaces try=rule spaces end_line else=rules { either(.try, .else) }
-    | " " spaces rule=rule spaces end_line { .rule }
-top:
-    | ( define | comment | blank )+ { .* }
diff --git a/bootpeg/pika/boot.py b/bootpeg/pika/boot.py
deleted file mode 100644
index 2fafaa8..0000000
--- a/bootpeg/pika/boot.py
+++ /dev/null
@@ -1,350 +0,0 @@
-from typing import Optional, Dict, Mapping, Callable
-
-import string
-import time
-import pathlib
-
-from ..utility import ascii_escapes
-from .peg import postorder_dfs, ParseFailure, MemoTable, Match
-from .front import (
-    Clause,
-    Literal,
-    Sequence,
-    Choice,
-    Nothing,
-    Anything,
-    Not,
-    And,
-    Repeat,
-    Reference,
-    Parser,
-    Debug,
-    Capture,
-    Rule,
-    Action,
-    Discard,
-    Commit,
-    transform,
-    chain,
-    either,
-    require,
-    Range,
-    Delimited,
-    unescape,
-)
-
-
-def sum_matches(match: Match) -> int:
-    total = 1
-    for sub_match in match.sub_matches:
-        total += sum_matches(sub_match)
-    return total
-
-
-def range_parse(source: str, parser: Parser):
-    """Parse ``source`` showing the matched range"""
-    start = time.perf_counter()
-    try:
-        match, memo = parser.parse(source)
-    except ParseFailure as err:
-        report_matches(err.memo)
-        raise
-    until = time.perf_counter()
-    print(
-        f"Duration: {until - start:4.3f}s",
-        f"Matches: {len(memo.matches)} / {len(source)} items",
-        f"[{len(memo.matches)/len(source):.1f} m/i]",
-        f"Efficiency: {sum_matches(match)} used / {len(memo.matches)} matched",
-        f"[{sum_matches(match) / len(memo.matches):.2f} u/m]",
-    )
-    report_matches(memo)
-    return Parser(
-        "top",
-        **{name: clause for name, clause in transform(match, memo, namespace)[0][0]},
-    )
-
-
-def report_matches(memo: MemoTable[str]):
-    """Show matched range(s) by a parsed memo table"""
-    longest_matches: Dict[int, Match] = {}
-    for match in memo.matches.values():
-        length, _, position, *_ = match
-        if isinstance(match.clause, Repeat) and isinstance(
-            match.clause.sub_clauses[0], Anything
-        ):
-            continue
-        if position not in longest_matches or longest_matches[position].length < length:
-            longest_matches[position] = match
-    report_pos = min(longest_matches)
-    print(
-        *(" " if i % 10 else i % 1000 // 100 for i in range(len(memo.source))), sep=""
-    )
-    print(*(" " if i % 10 else i % 100 // 10 for i in range(len(memo.source))), sep="")
-    print(*(i % 10 for i in range(len(memo.source))), sep="")
-    print(memo.source.translate(ascii_escapes))
-    while report_pos < len(memo.source):
-        if longest_matches[report_pos].length > 1:
-            print(
-                " " * report_pos,
-                "^",
-                "-" * (longest_matches[report_pos].length - 2),
-                "^",
-                sep="",
-            )
-            print(" " * report_pos, longest_matches[report_pos].clause)
-        report_pos += longest_matches[report_pos].length
-
-
-def display(parser: Parser):
-    """Display a parser in PEG form"""
-    try:
-        clauses, _, priorities = parser._parser
-    except Exception:
-        pass
-    else:
-        print("Total Rules:", len(list(postorder_dfs(clauses[parser.top]))))
-        print(f"{'named rule':<10} <- Prio -", "Clause")
-        for name, clause in sorted(clauses.items(), key=lambda n_c: priorities[n_c[1]]):
-            print(f"{name:<10} <- {priorities[clause]:4d} -", clause)
-            if isinstance(clause, Debug):
-                print("Debugs:", clause.sub_clauses[0])
-
-
-namespace: Mapping[str, Callable[..., Clause]] = {
-    expression.__name__: expression
-    for expression in (
-        Literal,
-        Sequence,
-        chain,
-        Choice,
-        either,
-        Nothing,
-        Anything,
-        Not,
-        And,
-        Repeat,
-        Reference,
-        Capture,
-        Rule,
-        Action,
-        Discard,
-        Commit,
-        require,
-        Range,
-        Delimited,
-        unescape,
-    )
-}
-
-
-end_line: Reference[str] = Reference("end_line")  # Literal("\n")
-
-#: Minimal parser required to bootstrap the actual parser
-min_parser = Parser(
-    "top",
-    literal=Rule(
-        Choice(
-            *(
-                Sequence(quote, Repeat(Sequence(Not(quote), Anything())), quote)
-                for quote in (Literal("'"), Literal('"'))
-            )
-        ),
-        Action("Literal(.*[1:-1])"),
-    ),
-    spaces=Rule(
-        Choice(Repeat(Literal(" ")), Nothing()),
-        Action("Discard()"),
-    ),
-    nothing=Rule(
-        Choice(Literal("''"), Literal('""')),
-        Action("Nothing()"),
-    ),
-    anything=Rule(
-        Literal("."),
-        Action("Anything()"),
-    ),
-    end_line=Rule(
-        Literal("\n"),
-        Action("Discard()"),
-    ),
-    identifier=Rule(
-        Repeat(Choice(*(Literal(ch) for ch in string.ascii_letters + "_"))),
-        Action(".*"),
-    ),
-    choice=Rule(
-        Sequence(
-            Capture("try", Reference("expr")),
-            Reference("spaces"),
-            Literal("|"),
-            Reference("spaces"),
-            Capture("else", Reference("expr")),
-        ),
-        Action("either(.try, .else)"),
-    ),
-    group=Rule(
-        Sequence(
-            Literal("("),
-            Reference("spaces"),
-            Capture("expr", Reference("expr")),
-            Reference("spaces"),
-            Literal(")"),
-        ),
-        Action(".expr"),
-    ),
-    sequence=Rule(
-        Sequence(
-            Capture("head", Reference("expr")),
-            Reference("spaces"),
-            Capture("tail", Reference("expr")),
-        ),
-        Action("chain(.head, .tail)"),
-    ),
-    repeat=Rule(
-        Sequence(Capture("expr", Reference("expr")), Reference("spaces"), Literal("+")),
-        Action("Repeat(.expr)"),
-    ),
-    reference=Rule(
-        Capture("name", Reference("identifier")),
-        Action("Reference(.name)"),
-    ),
-    capture=Rule(
-        Sequence(
-            Capture("name", Reference("identifier")),
-            Reference("spaces"),
-            Literal("="),
-            Reference("spaces"),
-            Capture("expr", Choice(Reference("reference"), Reference("group"))),
-        ),
-        Action("Capture(.name, .expr)"),
-    ),
-    reject=Rule(
-        Sequence(Literal("!"), Reference("spaces"), Capture("expr", Reference("expr"))),
-        Action("Not(.expr)"),
-    ),
-    expr=Rule(
-        Capture(
-            "expr",
-            Choice(
-                Reference("choice"),
-                Reference("sequence"),
-                Reference("repeat"),
-                Reference("capture"),
-                Reference("reference"),
-                Reference("group"),
-                Reference("literal"),
-                Reference("reject"),
-                Reference("anything"),
-                Reference("nothing"),
-            ),
-        ),
-        Action(".expr"),
-    ),
-    action=Rule(
-        Sequence(
-            Literal("{"),
-            Capture("body", Repeat(Sequence(Not(Literal("}")), Anything()))),
-            Literal("}"),
-        ),
-        Action("Action(.body)"),
-    ),
-    rule=Rule(
-        Sequence(
-            Literal("|"),
-            Reference("spaces"),
-            Capture("expr", Reference("expr")),
-            Reference("spaces"),
-            Capture("action", Reference("action")),
-        ),
-        Action("Rule(.expr, .action)"),
-    ),
-    rules=Choice(
-        Rule(
-            Sequence(
-                Literal(" "),
-                Reference("spaces"),
-                Capture("try", Reference("rule")),
-                Reference("spaces"),
-                end_line,
-                Capture("else", Reference("rules")),
-            ),
-            Action("either(.try, .else)"),
-        ),
-        Rule(
-            Sequence(
-                Literal(" "),
-                Reference("spaces"),
-                Capture("rule", Reference("rule")),
-                Reference("spaces"),
-                end_line,
-            ),
-            Action(".rule"),
-        ),
-    ),
-    define=Rule(
-        Sequence(
-            Capture("name", Reference("identifier")),
-            Literal(":"),
-            Reference("spaces"),
-            end_line,
-            Capture("rules", Reference("rules")),
-        ),
-        Action("(.name, .rules)"),
-    ),
-    comment=Rule(
-        Sequence(Literal("#"), Repeat(Sequence(Not(end_line), Anything())), end_line),
-        Action("Discard()"),
-    ),
-    blank=Rule(
-        Sequence(Reference("spaces"), end_line),
-        Action("Discard()"),
-    ),
-    top=Rule(
-        Repeat(Choice(Reference("define"), Reference("comment"), Reference("blank"))),
-        Action(".*"),
-    ),
-)
-boot_path = pathlib.Path(__file__).parent / "boot.bpeg"
-full_path = pathlib.Path(__file__).parent.parent / "grammars" / "bpeg.bpeg"
-
-
-def boot(base_parser: Parser, source: str) -> Parser:
-    head, memo = base_parser.parse(source)
-    assert head.length == len(source), f"parsed {head.length} < {len(source)}"
-    results, _ = transform(head, memo, namespace)
-    return Parser(
-        "top",
-        **{name: clause for name, clause in results[0]},
-    )
-
-
-_parser_cache: Optional[Parser] = None
-
-
-def bootpeg() -> Parser:
-    """Provide the basic bootpeg Pika parser"""
-    global _parser_cache
-    if _parser_cache is None:
-        _parser_cache = boot(min_parser, boot_path.read_text())
-    return _parser_cache
-
-
-if __name__ == "__main__":
-    parser = bootpeg()
-    print("Generation:", -1, "(min)")
-    display(parser)
-    for iteration in range(2):
-        with open(boot_path) as boot_peg:
-            parser = range_parse(
-                boot_peg.read(),
-                parser,
-            )
-            print("Generation:", iteration, "(boot)")
-            display(parser)
-    for iteration in range(2, 4):
-        with open(full_path) as base_peg:
-            parser = range_parse(
-                base_peg.read(),
-                parser,
-            )
-            print("Generation:", iteration, "(bpeg)")
-            display(parser)
diff --git a/bootpeg/pika/front.py b/bootpeg/pika/front.py
deleted file mode 100644
index 9cdb972..0000000
--- a/bootpeg/pika/front.py
+++ /dev/null
@@ -1,185 +0,0 @@
-from typing import Tuple, Optional
-
-from .peg import (
-    Literal,
-    Sequence,
-    Choice,
-    Nothing,
-    Anything,
-    Not,
-    And,
-    Repeat,
-    Reference,
-    Parser,
-    nested_str,
-    MemoTable,
-    MemoKey,
-    Match,
-    Clause,
-    Terminal,
-    D,
-)
-from .act import (
-    Debug,
-    Capture,
-    Rule,
-    transform,
-    Action,
-    Discard,
-    Commit,
-    CapturedParseFailure,
-)
-from ..utility import cache_hash
-
-__all__ = [
-    # peg
-    "Literal",
-    "Sequence",
-    "Choice",
-    "Nothing",
-    "Anything",
-    "Not",
-    "And",
-    "Repeat",
-    "Reference",
-    "Parser",
-    # act
-    "Debug",
-    "Capture",
-    "Rule",
-    "Action",
-    "Discard",
-    "Commit",
-    "CapturedParseFailure",
-    "transform",
-    # helpers
-    "chain",
-    "either",
-    "Range",
-    "Delimited",
-    "unescape",
-]
-
-
-def chain(left: Clause[D], right: Clause[D]) -> Sequence[D]:
-    """Chain two clauses efficiently"""
-    if isinstance(left, Sequence):
-        if isinstance(right, Sequence):
-            return Sequence(*left.sub_clauses, *right.sub_clauses)
-        return Sequence(*left.sub_clauses, right)
-    if isinstance(right, Sequence):
-        return Sequence(left, *right.sub_clauses)
-    return Sequence(left, right)
-
-
-def either(left: Clause[D], right: Clause[D]) -> Choice[D]:
-    """Choose between two clauses efficiently"""
-    if isinstance(left, Choice):
-        if isinstance(right, Choice):
-            return Choice(*left.sub_clauses, *right.sub_clauses)
-        return Choice(*left.sub_clauses, right)
-    if isinstance(right, Choice):
-        return Choice(left, *right.sub_clauses)
-    return Choice(left, right)
-
-
-def require(target: Clause[D]) -> Clause[D]:
-    """Commit to clauses efficiently"""
-    # It is tempting to try and remove `maybe_zero` clauses here.
-    # However, we cannot actually do that: There is no full grammar here, just clauses
-    # – but at least Reference needs the grammar to derive its `maybe_zero`.
-    if isinstance(target, Commit):
-        return target
-    elif isinstance(target, Sequence):
-        return Sequence(*(require(clause) for clause in target.sub_clauses))
-    else:
-        return Commit(target)
-
-
-class Range(Terminal[D]):
-    """
-    A terminal matching anything between two bounds inclusively
-    """
-
-    __slots__ = ("first", "last", "_length")
-    maybe_zero = False
-
-    def __init__(self, first: D, last: D):
-        assert len(first) == len(last) > 0, "Range bounds must be of same length"
-        self.first = first
-        self.last = last
-        self._length = len(first)
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        candidate = source[at : at + self._length]
-        if len(candidate) == self._length and self.first <= candidate <= self.last:
-            return Match(self._length, (), at, self)
-        return None
-
-    def __eq__(self, other):
-        return (
-            isinstance(other, Range)
-            and self.first == other.first
-            and self.last == other.last
-        )
-
-    def __hash__(self):
-        return hash((self.first, self.last))
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.first!r}, {self.last!r})"
-
-    def __str__(self):
-        return f"{self.first!r} - {self.last!r}"
-
-
-class Delimited(Clause[D]):
-    """
-    A pair of clauses with arbitrary intermediate filler
-    """
-
-    __slots__ = ("sub_clauses", "_hash")
-
-    @property
-    def maybe_zero(self):
-        assert not self.sub_clauses[0].maybe_zero
-        return False
-
-    def __init__(self, start: Clause[D], stop: Clause[D]):
-        self.sub_clauses = start, stop
-
-    @property
-    def triggers(self) -> "Tuple[Clause[D], ...]":
-        return self.sub_clauses[:1]
-
-    def match(self, source: D, at: int, memo: MemoTable) -> Optional[Match]:
-        start, stop = self.sub_clauses
-        head = memo[MemoKey(at, start)]
-        for offset in range(head.length, len(source) - at):
-            try:
-                tail = memo[MemoKey(at + offset, stop)]
-            except KeyError:
-                pass
-            else:
-                return Match(offset + tail.length, (head, tail), at, self)
-        return None
-
-    def __eq__(self, other):
-        return isinstance(other, Delimited) and self.sub_clauses == other.sub_clauses
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self.sub_clauses)
-
-    def __repr__(self):
-        start, stop = self.sub_clauses
-        return f"{self.__class__.__name__}(start={start!r}, stop={stop!r})"
-
-    def __str__(self):
-        return " :: ".join(map(nested_str, self.sub_clauses))
-
-
-def unescape(literal: str) -> str:
-    """Evaluate Python escape sequences in a string, e.g. ``r"\n"`` to ``"\n"``"""
-    # see https://stackoverflow.com/a/57192592/5349916
-    return literal.encode("latin-1", "backslashreplace").decode("unicode-escape")
diff --git a/bootpeg/pika/peg.py b/bootpeg/pika/peg.py
deleted file mode 100644
index a6adaff..0000000
--- a/bootpeg/pika/peg.py
+++ /dev/null
@@ -1,628 +0,0 @@
-"""
-Pika bottom-up PEG parser backend
-
-Based on https://arxiv.org/pdf/2005.06444.pdf, 2020 by Luke A. D. Hutchison
-"""
-from typing import (
-    NamedTuple,
-    Generic,
-    Dict,
-    Tuple,
-    Optional,
-    Iterable,
-    Set,
-    List,
-)
-import copy
-import heapq
-
-from ..typing import D
-from ..utility import cache_hash, safe_recurse
-
-
-# Ascending memoization
-class MemoKey(NamedTuple):
-    position: int
-    clause: "Clause"
-
-
-class Match(NamedTuple):
-    length: int
-    sub_matches: "Tuple[Match, ...]"
-    position: int
-    clause: "Clause"
-    priority: int = 0
-
-    def overrules(self, other: "Match") -> bool:
-        """Whether ``self`` is better than an ``other`` match *for the same clause*"""
-        assert (
-            self.position == other.position and self.clause == other.clause
-        ), f"{self.position}=={other.position} and {self.clause}=={other.clause}"
-        return self.priority < other.priority or self.length > other.length
-
-    def __repr__(self):
-        return (
-            f"{self.__class__.__name__}(length={self.length}, "
-            f"sub_matches=<{len(self.sub_matches)}>, "
-            f"position={self.position}, "
-            f"clause={self.clause!r}, "
-            f"priority={self.priority})"
-        )
-
-
-class MemoTable(Generic[D]):
-    __slots__ = ("matches", "source")
-
-    def __init__(self, source: D):
-        self.source = source
-        self.matches: Dict[MemoKey, Match] = {}
-
-    def __getitem__(self, item: MemoKey) -> Match:
-        try:
-            return self.matches[item]
-        except KeyError:
-            if type(item.clause) is Not:
-                match = item.clause.match(self.source, item.position, self)
-                if match is not None:
-                    self.matches[item] = match
-                    return match
-            elif item.clause.maybe_zero:
-                match = self.matches[item] = Match(0, (), item.position, item.clause)
-                return match
-            raise  # If you see this, the priorities are wrong. Please open a ticket!
-
-    def insert(self, item: MemoKey, match: Match) -> bool:
-        """
-        Insert new match for item and return whether an update occurred
-        """
-        prev_match = self.matches.get(item)
-        if prev_match is None or match.overrules(prev_match):
-            self.matches[item] = match
-            return True
-        return False
-
-
-# Base Grammar elements
-class Clause(Generic[D]):
-    """
-    Description for matching a specific grammar part
-    """
-
-    __slots__ = ()
-    #: whether this clause can match zero-length source
-    maybe_zero: bool
-    #: concrete clauses that make up this clause
-    sub_clauses: "Tuple[Clause[D], ...]"
-
-    @property
-    def triggers(self) -> "Tuple[Clause[D], ...]":
-        """Clauses whose match implies that this clause can match"""
-        return self.sub_clauses
-
-    def match(self, source: D, at: int, memo: MemoTable) -> Optional[Match]:
-        """
-        Attempt to match ``source`` ``at`` a specific position
-
-        :param source: entire match domain
-        :param at: index at which to match ``source``
-        :param memo: Previously memoized matches in the ``source``
-        :return: A ``Match`` on success or :py:data:`None` otherwise
-        """
-        raise NotImplementedError(f"Method 'match' for {self.__class__.__name__}")
-
-    def bind(
-        self, clauses: "Dict[str, Clause[D]]", canonicals: "Dict[Clause[D], Clause[D]]"
-    ):
-        """Inplace bind and deduplicate in the context of named ``clauses``"""
-        if self not in canonicals:
-            canonicals[self] = self
-            sub_clauses = tuple(
-                clause.bind(clauses, canonicals) for clause in self.sub_clauses
-            )
-            self.sub_clauses = sub_clauses
-        return canonicals[self]
-
-
-class Terminal(Clause[D]):
-    """
-    Any clause that does not depend on other clauses
-    """
-
-    __slots__ = ()
-    sub_clauses = ()
-
-    def bind(self, clauses, canonicals):
-        if self not in canonicals:
-            canonicals[self] = self
-        return canonicals[self]
-
-
-def postorder_dfs(
-    *bases: Clause[D], _seen: Optional[Set[Clause[D]]] = None
-) -> Iterable[Clause[D]]:
-    """
-    Iterate over all clauses reachable from ``bases`` in depth first postorder
-
-    Clauses are deduplicated, making this robust for cycles. The result is in
-    bottom-up topological sort order:
-    a clause will always occur after all its subclauses.
-    """
-    _seen = _seen if _seen is not None else set()
-    for base in bases:
-        if base in _seen:
-            continue
-        _seen.add(base)
-        for sub_clause in reversed(base.sub_clauses):
-            if sub_clause not in _seen:
-                yield from postorder_dfs(sub_clause, _seen=_seen)
-        yield base
-
-
-def nested_str(clause: Clause):
-    """Helper to format sub-clauses with grouping parentheses as required"""
-    if isinstance(clause, (Reference, Terminal, Not, Repeat)):
-        return str(clause)
-    return f"({clause})"
-
-
-# Specific Grammar elements
-class Nothing(Terminal[D]):
-    """
-    The empty terminal "ε", matches at any point consuming nothing
-    """
-
-    maybe_zero = True
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        return Match(0, (), at, self)
-
-    def __eq__(self, other):
-        return isinstance(other, Nothing)
-
-    def __hash__(self):
-        return hash(Nothing)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}()"
-
-    def __str__(self):
-        return "ε"
-
-
-class Anything(Terminal[D]):
-    """
-    The Any terminal ".", matches at any point with sufficient remainder
-    """
-
-    __slots__ = ("length",)
-    maybe_zero = False
-
-    def __init__(self, length=1):
-        self.length = length
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        if at + self.length <= len(source):
-            return Match(self.length, (), at, self)
-        return None
-
-    def __eq__(self, other):
-        return isinstance(other, Anything) and self.length == other.length
-
-    def __hash__(self):
-        return self.length
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.length})"
-
-    def __str__(self):
-        return "." * self.length
-
-
-class Literal(Terminal[D]):
-    """
-    A terminal matching a fixed literal
-    """
-
-    __slots__ = ("value", "_len")
-    maybe_zero = False
-
-    def __init__(self, value: D):
-        assert value, "Literal may not be empty"
-        self.value = value
-        self._len = len(value)
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        if source[at : at + self._len] == self.value:
-            return Match(self._len, (), at, self)
-        return None
-
-    def __eq__(self, other):
-        return isinstance(other, Literal) and self.value == other.value
-
-    def __hash__(self):
-        return hash(self.value)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self.value!r})"
-
-    def __str__(self):
-        return repr(self.value)
-
-
-class Sequence(Clause[D]):
-    """
-    A sequence of clauses, matching if all ``sub_clauses`` match in order
-    """
-
-    __slots__ = ("sub_clauses", "_maybe_zero", "_hash")
-
-    @property
-    def maybe_zero(self):
-        if self._maybe_zero is None:
-            self._maybe_zero = all(clause.maybe_zero for clause in self.sub_clauses)
-        return self._maybe_zero
-
-    @property
-    def triggers(self) -> "Tuple[Clause[D], ...]":
-        first_required = next(
-            (
-                n
-                for n, scl in enumerate(self.sub_clauses, start=1)
-                if not scl.maybe_zero
-            ),
-            len(self.sub_clauses),
-        )
-        return self.sub_clauses[:first_required]
-
-    def __init__(self, *sub_clauses: Clause[D]):
-        self.sub_clauses = sub_clauses
-        self._maybe_zero = None
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        offset, matches = at, ()  # type: int, Tuple[Match, ...]
-        try:
-            for sub_clause in self.sub_clauses:
-                sub_match = memo[MemoKey(offset, sub_clause)]
-                matches += (sub_match,)
-                offset += sub_match.length
-            return Match(offset - at, matches, at, self)
-        except KeyError:
-            return None
-
-    def __eq__(self, other):
-        return isinstance(other, Sequence) and self.sub_clauses == other.sub_clauses
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self.sub_clauses)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({', '.join(map(repr, self.sub_clauses))})"
-
-    def __str__(self):
-        return " ".join(map(nested_str, self.sub_clauses))
-
-
-class Choice(Clause[D]):
-    """
-    A choice of clauses, matching with the first matching clause of ``sub_clauses``
-    """
-
-    __slots__ = ("sub_clauses", "_maybe_zero", "_hash")
-
-    @property
-    def maybe_zero(self):
-        if self._maybe_zero is None:
-            self._maybe_zero = any(clause.maybe_zero for clause in self.sub_clauses)
-        return self._maybe_zero
-
-    def __init__(self, *sub_clauses: Clause[D]):
-        self.sub_clauses = sub_clauses
-        self._maybe_zero = None
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        for index, sub_clause in enumerate(self.sub_clauses):
-            try:
-                sub_match = memo[MemoKey(at, sub_clause)]
-            except KeyError:
-                pass
-            else:
-                return Match(sub_match.length, (sub_match,), at, self, index)
-        assert False, "Choice can only trigger if any sub_clause matched"
-
-    def __eq__(self, other):
-        return isinstance(other, Choice) and self.sub_clauses == other.sub_clauses
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self.sub_clauses)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({', '.join(map(repr, self.sub_clauses))})"
-
-    def __str__(self):
-        return " | ".join(map(nested_str, self.sub_clauses))
-
-
-class Repeat(Clause[D]):
-    """
-    A repetition of a clause, matching if the sub_clause matches at least once
-    """
-
-    __slots__ = ("_sub_clause", "_hash")
-
-    @property
-    def maybe_zero(self):
-        assert not self._sub_clause.maybe_zero, "repeated zero matches infinitely"
-        return self._sub_clause.maybe_zero
-
-    @property
-    def sub_clauses(self) -> Tuple[Clause[D], ...]:
-        return (self._sub_clause,)
-
-    @sub_clauses.setter
-    def sub_clauses(self, values: Tuple[Clause[D], ...]):
-        (self._sub_clause,) = values
-
-    def __init__(self, sub_clause: Clause[D]):
-        self._sub_clause = sub_clause
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        sub_match = memo[MemoKey(at, self._sub_clause)]
-        if sub_match.length == 0:
-            return Match(sub_match.length, (sub_match,), at, self)
-        # check if there was a previous match by us at the next position
-        try:
-            prev_match = memo[MemoKey(at + sub_match.length, self)]
-        except KeyError:
-            return Match(sub_match.length, (sub_match,), at, self)
-        else:
-            return Match(
-                sub_match.length + prev_match.length, (sub_match, prev_match), at, self
-            )
-
-    def __eq__(self, other):
-        return isinstance(other, Repeat) and self._sub_clause == other._sub_clause
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self._sub_clause)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self._sub_clause!r})"
-
-    def __str__(self):
-        return f"{nested_str(self._sub_clause)}+"
-
-
-class Not(Clause[D]):
-    """
-    The inversion of a clause, matching if the sub_clause does not match
-    """
-
-    __slots__ = ("_sub_clause", "_hash")
-    maybe_zero = True
-
-    @property
-    def sub_clauses(self) -> Tuple[Clause[D], ...]:
-        return (self._sub_clause,)
-
-    @sub_clauses.setter
-    def sub_clauses(self, value: Tuple[Clause[D], ...]):
-        (self._sub_clause,) = value
-
-    @property
-    def triggers(self):
-        return ()
-
-    def __init__(self, sub_clause: Clause[D]):
-        self._sub_clause = sub_clause
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        try:
-            _ = memo[MemoKey(at, self._sub_clause)]
-        except KeyError:
-            return Match(0, (), at, self)
-        else:
-            return None
-
-    def __eq__(self, other):
-        return isinstance(other, Not) and self._sub_clause == other._sub_clause
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self._sub_clause)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self._sub_clause!r})"
-
-    def __str__(self):
-        return f"!{nested_str(self._sub_clause)}"
-
-
-class And(Clause[D]):
-    """
-    The lookahead of a clause, matching if the sub_clause matches but not consuming it
-    """
-
-    __slots__ = ("_sub_clause", "_hash")
-
-    @property
-    def maybe_zero(self):
-        assert not self._sub_clause.maybe_zero, "lookahead of zero match"
-        return False
-
-    @property
-    def sub_clauses(self) -> Tuple[Clause[D], ...]:
-        return (self._sub_clause,)
-
-    @sub_clauses.setter
-    def sub_clauses(self, values: Tuple[Clause[D]]):
-        (self._sub_clause,) = values
-
-    def __init__(self, sub_clause: Clause[D]):
-        self._sub_clause = sub_clause
-
-    def match(self, source: D, at: int, memo: MemoTable):
-        try:
-            sub_match = memo[MemoKey(at, self._sub_clause)]
-        except KeyError:
-            return None
-        else:
-            return Match(0, (sub_match,), at, self)
-
-    def __eq__(self, other):
-        return isinstance(other, And) and self._sub_clause == other._sub_clause
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self._sub_clause)
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}({self._sub_clause!r})"
-
-    def __str__(self):
-        return f"&{nested_str(self._sub_clause)}"
-
-
-# Grammar Definitions
-class Reference(Clause[D]):
-    """
-    Placeholder for another named clause, matching if the target matches
-    """
-
-    __slots__ = ("target", "_sub_clause", "_maybe_zero", "_hash")
-
-    def __init__(self, target: str):
-        self.target = target
-        self._sub_clause: Optional[Clause[D]] = None
-        self._maybe_zero: Optional[bool] = None
-
-    @property
-    @safe_recurse(default=False)
-    def maybe_zero(self):
-        if self._maybe_zero is None:
-            assert self._sub_clause is not None, f"{self} must be bound in a grammar"
-            self._maybe_zero = self._sub_clause.maybe_zero
-        return self._maybe_zero
-
-    @property
-    def sub_clauses(self):
-        assert self._sub_clause is not None, f"{self} must be bound in a grammar"
-        return (self._sub_clause,)
-
-    def match(self, source: D, at: int, memo: MemoTable) -> Optional[Match]:
-        sub_match = memo[MemoKey(at, self._sub_clause)]
-        return Match(sub_match.length, (sub_match,), at, self)
-
-    def bind(
-        self, clauses: "Dict[str, Clause[D]]", canonicals: "Dict[Clause[D], Clause[D]]"
-    ):
-        """Bind this clause into the context of ``clauses`` inplace"""
-        if self not in canonicals:
-            canonicals[self] = self
-            assert self._sub_clause is None
-            self._sub_clause = clauses[self.target].bind(clauses, canonicals)
-        return canonicals[self]
-
-    def __eq__(self, other):
-        return isinstance(other, Reference) and self.target == other.target
-
-    @cache_hash
-    def __hash__(self):
-        return hash(self.target)
-
-    def __repr__(self):
-        if self._sub_clause is None:
-            return f"{self.__class__.__name__}({self.target!r},)"
-        return f"{self.__class__.__name__}({self.target!r})"
-
-    def __str__(self):
-        if self._sub_clause is None:
-            return f"<{self.target}>"
-        return f"{self.target}"
-
-
-class ParseFailure(Exception):
-    """
-    Parsing failed for ``source`` up to the current ``memo`` progress
-    """
-
-    def __init__(self, memo: MemoTable):
-        self.memo = memo
-        super().__init__("Failed to parse source")
-
-
-class Parser(Generic[D]):
-    __slots__ = "top", "clauses", "_compiled_parser"
-
-    def __init__(self, __top__: str, **clauses: Clause[D]):
-        self.top = __top__
-        self.clauses = clauses
-        self._compiled_parser = None
-
-    def _prepare(self):
-        if self._compiled_parser is None:
-            self._compiled_parser = self._compile(self.top, self.clauses)
-
-    @property
-    def _parser(self):
-        if self._compiled_parser is None:
-            self._compiled_parser = self._compile(self.top, self.clauses)
-        return self._compiled_parser
-
-    def parse(self, source: D):
-        """Parse a ``source`` sequence"""
-        owned_clauses, triggers, priorities = self._parser
-        terminals: List[Tuple[int, Clause[D]]] = [
-            (-i, clause)
-            for i, clause in enumerate(priorities)
-            if isinstance(clause, Terminal) and not clause.maybe_zero
-        ]
-        heapq.heapify(terminals)
-        memo = MemoTable(source)
-        for at in reversed(range(len(source))):
-            outstanding = terminals[:]
-            while outstanding:
-                clause = heapq.heappop(outstanding)[1]
-                result = clause.match(source, at, memo)
-                if result is not None and memo.insert(MemoKey(at, clause), result):
-                    for parent_clause in triggers[clause]:
-                        heapq.heappush(
-                            outstanding, (priorities[parent_clause], parent_clause)
-                        )
-        try:
-            return memo[MemoKey(0, owned_clauses[self.top])], memo
-        except KeyError:
-            raise ParseFailure(memo) from None
-
-    def _compile(self, top: str, clauses: Dict[str, Clause[D]]):
-        # TODO: add magic
-        #   - embed non-recursive rules
-        owned_clauses = self._bind_references(top, clauses)
-        triggers = self._compile_triggers(owned_clauses[top])
-        priorities = self._compile_priorities(owned_clauses[top])
-        return owned_clauses, triggers, priorities
-
-    def _bind_references(
-        self, top: str, clauses: Dict[str, Clause[D]]
-    ) -> Dict[str, Clause[D]]:
-        owned_clauses = copy.deepcopy(clauses)
-        owned_clauses[top].bind(owned_clauses, {})
-        return owned_clauses
-
-    @staticmethod
-    def _compile_triggers(
-        top_clause: Clause[D],
-    ) -> Dict[Clause[D], Tuple[Clause[D], ...]]:
-        triggers: Dict[Clause[D], Tuple[Clause[D], ...]] = {
-            trigger: () for trigger in postorder_dfs(top_clause)
-        }
-        for parent in postorder_dfs(top_clause):
-            for trigger in parent.triggers:
-                triggers[trigger] += (parent,)
-        return triggers
-
-    @staticmethod
-    def _compile_priorities(top_clause: Clause[D]) -> Dict[Clause[D], int]:
-        return {clause: prio for prio, clause in enumerate(postorder_dfs(top_clause))}
diff --git a/bootpeg/typing.py b/bootpeg/typing.py
index f56a6ea..8f7d681 100644
--- a/bootpeg/typing.py
+++ b/bootpeg/typing.py
@@ -28,17 +28,5 @@ def __len__(self) -> int:
     def __getitem__(self: D, item: slice) -> D:
         raise NotImplementedError
 
-    def __le__(self: D, other: D) -> bool:
-        raise NotImplementedError
-
-
-class BootPegParser(Protocol[D_contra, R]):
-    """
-    Protocol for all :py:mod:`bootpeg` parsers creating parsers
-    """
-
-    def __call__(self, source: D_contra, top: str = "top") -> R:
-        """
-        Parse a parser ``source`` with a known ``top`` rule
-        """
+    def __lt__(self: D, other: D) -> bool:
         raise NotImplementedError
diff --git a/bootpeg/utility.py b/bootpeg/utility.py
index 125a639..847fa31 100644
--- a/bootpeg/utility.py
+++ b/bootpeg/utility.py
@@ -1,63 +1,4 @@
-from typing import Tuple
-from functools import wraps
-
-ascii_escapes = str.maketrans(
-    {
-        ord("\n"): "␤",
-        ord("\t"): "␉",
-        0: "␀",
-    }
-)
-
-
-def mono(ascii: str) -> str:
-    """Convert an ascii string to a monospace unicode form"""
-    return ascii.translate(ascii_escapes)
-
-
-def cache_hash(__hash__):
-    """
-    Cache the ``__hash__`` of an object to a ``_hash`` attribute
-
-    Applied as a decorator to the ``__hash__`` method of a class.
-    Instances must have a writeable ``_hash`` attribute that is *not* initialised.
-    """
-
-    @wraps(__hash__)
-    def cached_hash(self):
-        try:
-            return self._hash
-        except AttributeError:
-            _hash = self._hash = __hash__(self)
-            return _hash
-
-    return cached_hash
-
-
-def safe_recurse(default=False):
-    """
-    Mark a function/method as recursion-safe, returning ``default`` on recursion
-
-    This function is *not* threadsafe.
-    """
-
-    def decorator(method):
-        repr_running = set()
-
-        @wraps(method)
-        def wrapper(self):
-            if self in repr_running:
-                return default
-            repr_running.add(self)
-            try:
-                result = method(self)
-            finally:
-                repr_running.remove(self)
-            return result
-
-        return wrapper
-
-    return decorator
+from typing import Tuple, TypeVar, Type
 
 
 def grammar_resource(location: str) -> Tuple[str, str]:
@@ -71,3 +12,37 @@ def grammar_resource(location: str) -> Tuple[str, str]:
         location = __import__("__main__").__spec__.name
     package, _, name = location.rpartition(".")
     return package, f"{name}.bpeg"
+
+
+T = TypeVar("T")
+
+
+def slotted(cls: Type[T]) -> Type[T]:
+    """
+    Class decorator to add ``__repr__``, ``__eq__`` and ``__hash__`` from ``__slots__``
+    """
+    # use dict comprehension to get unique insertion order
+    slots = tuple(
+        {
+            name: None
+            for scls in reversed(cls.__mro__)
+            for name in getattr(scls, "__slots__", ())
+        }
+    )
+
+    def __repr__(self: T):
+        members = ", ".join(f"{name}={getattr(self, name)!r}" for name in slots)
+        return f"{self.__class__.__name__}({members})"
+
+    def __eq__(self: T, other) -> bool:
+        return isinstance(other, type(self)) and all(
+            getattr(self, name) == getattr(other, name) for name in slots
+        )
+
+    def __hash__(self: T) -> int:
+        return hash((*(getattr(self, name) for name in slots), hash(type(self))))
+
+    cls.__repr__ = __repr__
+    cls.__eq__ = __eq__
+    cls.__hash__ = __hash__
+    return cls
diff --git a/bootpeg_examples/math.bpeg b/bootpeg_examples/math.bpeg
index 01322db..5202c9c 100644
--- a/bootpeg_examples/math.bpeg
+++ b/bootpeg_examples/math.bpeg
@@ -10,35 +10,36 @@
 # - binary operations: `add_sub`, `mul_div`
 #   Rules composing several other rules,
 #   adding logical structure such as precedence and associativity.
-# The `top` rule always is the entry point for parsing:
+# The first rule always is the entry point for parsing:
 # it is applied to the entire input, delegating to other rules to match and structure.
 
+# parsing starts at the first rule
+# "!." means "not any" – this only matches the end of the source
+top:
+    | add_sub spacing !.
+
 # whitespace is explicit in bootpeg
 spacing:
     | ' '*
 
-number:
-    | "1" - "9" ("0" - "9")* "." ~ "1" - "9" ("0" - "9")* { decimal(.*) }
-    | "1" - "9" ("0" - "9")* { integer(.*) }
-negation:
-    | "-" ~ spacing expr=top { neg(.expr) }
-group:
-    | '(' ~ spacing exp=top spacing ')' { .exp }
-
 # This is a precedence climbing grammar using left-recursion for left-associative rules
 add_sub:
-    | lhs=add_sub spacing '+' ~ spacing rhs=mul_div { add(.lhs, .rhs) }
-    | lhs=add_sub spacing '-' ~ spacing rhs=mul_div { sub(.lhs, .rhs) }
-    | prec=mul_div { .prec }
+    | lhs=add_sub spacing '+' ~ spacing rhs=mul_div { add(lhs, rhs) }
+    | lhs=add_sub spacing '-' ~ spacing rhs=mul_div { sub(lhs, rhs) }
+    | mul_div
 mul_div:
-    | lhs=mul_div spacing '*' ~ spacing rhs=terminal { mul(.lhs, .rhs) }
-    | lhs=mul_div spacing '/' ~ spacing rhs=terminal { div(.lhs, .rhs) }
-    | prec=terminal { .prec }
+    | lhs=mul_div spacing '*' ~ spacing rhs=terminal { mul(lhs, rhs) }
+    | lhs=mul_div spacing '/' ~ spacing rhs=terminal { div(lhs, rhs) }
+    | terminal
 terminal:
     | number
     | negation
     | group
 
-# the top-rule is
-top:
-    | add_sub spacing
+number:
+    | literal=("1"-"9" "0"-"9"* "." ~ "1"-"9" "0"-"9"*) { decimal(literal) }
+    | literal=("1"-"9" "0"-"9"*) { integer(literal) }
+negation:
+    | "-" ~ spacing expr=top { neg(expr) }
+group:
+    | '(' ~ spacing exp=top spacing ')'


Test patch:
diff --git a/tests/test_boot.py b/tests/test_boot.py
index 50d7c04..ae91072 100644
--- a/tests/test_boot.py
+++ b/tests/test_boot.py
@@ -1,30 +1,25 @@
+import importlib_resources
+import pickle
+
 import pytest
 
-from bootpeg.pika import boot
+from bootpeg import create_parser, bootpeg_actions
+from bootpeg.apegs import boot
 
 
 def test_bootstrap():
-    boot_peg = boot.boot_path.read_text()
-    parser = boot.min_parser
+    source = importlib_resources.read_text("bootpeg.grammars", "bpeg.bpeg")
+    parser = boot.boot_parser
     # ensure each parser handles itself
-    for _ in range(2):
-        parser = boot.boot(parser, boot_peg)
+    for _ in range(5):
+        parser = create_parser(source, parser, bootpeg_actions)
 
 
-def test_escalate():
-    full_peg = boot.full_path.read_text()
-    parser = boot.bootpeg()
+@pytest.mark.parametrize("protocol", list(range(2, pickle.HIGHEST_PROTOCOL + 1)))
+def test_pickle(protocol):
+    """Ensure that parsers can be pickled"""
+    source = importlib_resources.read_text("bootpeg.grammars", "bpeg.bpeg")
+    parser = boot.boot_parser
     for _ in range(2):
-        parser = boot.boot(parser, full_peg)
-
-
-def test_features():
-    full_peg = boot.full_path.read_text()
-    parser = boot.boot(boot.bootpeg(), full_peg)
-    opt_repeat = boot.boot(parser, 'rule:\n    | [ " "+ ]\n')
-    non_repeat = boot.boot(parser, 'rule:\n    | " "*\n')
-    assert opt_repeat.clauses == non_repeat.clauses
-    with pytest.raises(TypeError):
-        boot.boot(parser, 'rule:\n    | [ " "+ ] { .missing }\n')
-    with pytest.raises(TypeError):
-        boot.boot(parser, 'rule:\n    | extra=([ " "+ ]) { () }\n')
+        parser = pickle.loads(pickle.dumps(parser, protocol=protocol))
+        parser = create_parser(source, parser, bootpeg_actions)
diff --git a/tests/test_examples/test_math.py b/tests/test_examples/test_math.py
index 277f178..517f231 100644
--- a/tests/test_examples/test_math.py
+++ b/tests/test_examples/test_math.py
@@ -26,7 +26,7 @@ def test_as_main():
     ("-3/-4", math.Rational(False, 3, 4)),
     ("3.5 * -2", math.Rational(True, 7, 1)),
     ("12.5 - 2 + 3.5", math.Rational(False, 14, 1)),
-    # ("12.5 + 3.5 - 2", math.Rational(False, 14, 1)),
+    ("12.5 + 3.5 - 2", math.Rational(False, 14, 1)),
     ("8 - 2 - 2 - 2 - 2", math.Rational(False, 0, 1)),
     ("8 + 2 + 2 + 2 + 2", math.Rational(False, 16, 1)),
 ]
diff --git a/tests/test_grammars/test_bpeg.py b/tests/test_grammars/test_bpeg.py
index e93174b..3acc8ba 100644
--- a/tests/test_grammars/test_bpeg.py
+++ b/tests/test_grammars/test_bpeg.py
@@ -2,37 +2,44 @@
 
 from bootpeg import create_parser
 from bootpeg.grammars import bpeg
-from bootpeg.pika.front import (
-    Rule,
-    Literal,
-    Nothing,
-    Anything,
+
+from bootpeg.apegs import (
+    Value,
+    Range,
+    Any,
+    Empty,
     Sequence,
     Choice,
     Repeat,
-    Not,
     And,
+    Not,
+    Entail,
+    Capture,
+    Transform,
     Reference,
-    Range,
-    Delimited,
-    CapturedParseFailure,
+    Rule,
+    ParseFailure,
 )
 
 
 clauses = [
-    Nothing(),
-    Anything(1),
-    *(Literal(literal) for literal in ("A", "x", "ß", " ")),
-    Sequence(Literal("A"), Literal("B"), Literal("A")),
-    Sequence(Literal(" "), Literal(" ")),
-    Choice(Literal("a"), Literal("b"), Nothing()),
-    Repeat(Literal("x")),
-    Repeat(Sequence(Literal("x"), Literal("y"), Literal("z"))),
-    Not(Literal("a")),
-    And(Literal("a")),
+    Empty(),
+    Any(1),
+    *(Value(literal) for literal in ("A", "x", "ß", " ")),
+    Sequence(Value("A"), Value("B"), Value("A")),
+    Sequence(Value(" "), Value(" ")),
+    Choice(Value("a"), Value("b"), Empty()),
+    Repeat(Value("x")),
+    Repeat(Sequence(Value("x"), Value("y"), Value("z"))),
+    Not(Value("a")),
+    And(Value("a")),
     Reference("some_rule"),
     Range("a", "b"),
-    Delimited(Literal("'"), Literal("'")),
+    Entail(Value("a"), Value("b")),
+    Sequence(Value("head"), Entail(Value("a"), Value("b"))),
+    Capture(Value("expr"), "name", True),
+    Capture(Value("expr"), "name", False),
+    Transform(Value("body"), "{True}"),
 ]
 
 
@@ -41,40 +48,34 @@ def test_roundtrip(clause):
     clause = clause
     literal = bpeg.unparse(clause)
     assert literal
-    parsed_rule: Rule = bpeg.parse(f"parse_test:\n    | {literal}\n").clauses[
-        "parse_test"
-    ]
-    assert parsed_rule.sub_clauses[0] == clause
+    parsed_rule: Rule = bpeg.parse(f"parse_test:\n    | {literal}\n").rules[0]
+    assert parsed_rule.sub_clause.sub_clauses[0] == clause
 
 
-commit_failures = (("top:\n | ~\n", {9}),)
+commit_failures = (("top:\n | ~\n", 9),)
 
 
-@pytest.mark.parametrize("source, positions", commit_failures)
-def test_commit(source, positions):
+@pytest.mark.parametrize("source, position", commit_failures)
+def test_commit(source, position):
     try:
         bpeg.parse(source)
-    except CapturedParseFailure as cpf:
-        assert cpf.positions == positions
+    except ParseFailure as pf:
+        assert pf.index == position
     else:
-        assert not positions, "Expected parse failures, found none"
+        assert not position, "Expected parse failures, found none"
 
 
 @pytest.mark.parametrize("source", ["a", "bcde"])
 def test_multiuse_actions(source):
     """Test that using the same capture multiple times is valid"""
-    multiuse_parse = create_parser(
-        "top:\n    | a=(.*) { (.a, .a) }\n", bpeg, actions={}
-    )
+    multiuse_parse = create_parser("top:\n    | a=(.*) { (a, a) }\n", bpeg)
     result = multiuse_parse(source)
     assert result == (source, source)
 
 
 def test_not_anything():
-    """Test that ``Anything`` does not match after the end"""
-    not_anything_parse = create_parser(
-        "top:\n    | a=(.) !. { (.a) }\n", bpeg, actions={}
-    )
+    """Test that ``Any`` does not match after the end"""
+    not_anything_parse = create_parser("top:\n    | a=(.) !. { (a) }\n", bpeg)
     assert not_anything_parse("b") == "b"
-    with pytest.raises(Exception):
+    with pytest.raises(ParseFailure):
         not_anything_parse("bb")
diff --git a/tests/test_grammars/test_peg.py b/tests/test_grammars/test_peg.py
index a04c78d..66fce15 100644
--- a/tests/test_grammars/test_peg.py
+++ b/tests/test_grammars/test_peg.py
@@ -4,35 +4,43 @@
 
 from bootpeg import create_parser
 from bootpeg.grammars import peg
-from bootpeg.pika.front import (
-    Rule,
-    Literal,
-    Nothing,
-    Anything,
+from bootpeg.apegs import (
+    Value,
+    Range,
+    Any,
+    Empty,
     Sequence,
     Choice,
     Repeat,
-    Not,
     And,
+    Not,
+    Entail,
+    Capture,
+    Transform,
     Reference,
-    Range,
-    Delimited,
+    Rule,
+    Parser,
 )
 
 
 roundtrip_clauses = [
-    Nothing(),
-    Anything(1),
-    *(Literal(literal) for literal in ("A", "x", "ß", " ")),
-    Sequence(Literal("A"), Literal("B"), Literal("A")),
-    Sequence(Literal(" "), Literal(" ")),
-    Choice(Literal("a"), Literal("b"), Nothing()),
-    Repeat(Literal("x")),
-    Repeat(Sequence(Literal("x"), Literal("y"), Literal("z"))),
-    Not(Literal("a")),
-    And(Literal("a")),
+    Empty(),
+    Any(1),
+    *(Value(literal) for literal in ("A", "x", "ß", " ")),
+    Sequence(Value("A"), Value("B"), Value("A")),
+    Sequence(Value(" "), Value(" ")),
+    Choice(Value("a"), Value("b"), Empty()),
+    Repeat(Value("x")),
+    Repeat(Sequence(Value("x"), Value("y"), Value("z"))),
+    Not(Value("a")),
+    And(Value("a")),
     Reference("some_rule"),
     Range("a", "b"),
+    Entail(Value("a"), Value("b")),
+    Sequence(Value("head"), Entail(Value("a"), Value("b"))),
+    Capture(Value("expr"), "name", True),
+    Capture(Value("expr"), "name", False),
+    Transform(Value("body"), "{True}"),
 ]
 
 
@@ -41,29 +49,8 @@ def test_roundtrip(clause):
     clause = clause
     literal = peg.unparse(clause)
     assert literal
-    parsed_rule: Rule = peg.parse(f"parse_test <- {literal}\n").clauses["parse_test"]
-    assert parsed_rule.sub_clauses[0] == clause
-
-
-emulated_clauses = [
-    (
-        Delimited(Literal("A"), Literal("B")),
-        Sequence(
-            Literal("A"),
-            Choice(Repeat(Sequence(Not(Literal("B")), Anything(1))), Nothing()),
-            Literal("B"),
-        ),
-    ),
-]
-
-
-@pytest.mark.parametrize("clause, emulation", emulated_clauses)
-def test_roundtrip_emulate(clause, emulation):
-    clause = clause
-    literal = peg.unparse(clause)
-    assert literal
-    parsed_rule: Rule = peg.parse(f"parse_test <- {literal}\n").clauses["parse_test"]
-    assert parsed_rule.sub_clauses[0] == emulation
+    parsed_rule: Rule = peg.parse(f"parse_test <- {literal}\n").rules[0]
+    assert parsed_rule.sub_clause == clause
 
 
 sys.setrecursionlimit(30000)
@@ -72,7 +59,7 @@ def test_roundtrip_emulate(clause, emulation):
 # Some fixes due to errors in the original grammar
 peg_grammar = r"""
 # Hierarchical syntax
-Grammar    <- (Spacing Definition)+ # EndOfFile
+Grammar    <- (Spacing Definition)+ EndOfFile
 Definition <- Identifier LEFTARROW Expression Spacing
 
 Expression <- Sequence (SLASH Sequence)*
@@ -119,8 +106,13 @@ def test_roundtrip_emulate(clause, emulation):
 
 def test_parse_reference():
     """Parse the PEG reference grammar"""
-    parse = create_parser(peg_grammar, actions={}, dialect=peg, top="Grammar")
-    assert parse(peg_grammar)
+    parse = create_parser(peg_grammar, dialect=peg)
+    # reference PEG does not understand results, but bootpeg requires them
+    parse = Parser(
+        Rule(parse.rules[0].name, Transform(parse.rules[0].sub_clause, "()")),
+        *parse.rules[1:],
+    )
+    assert parse(peg_grammar) == ()
 
 
 def test_parse_short():
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index fe59ca6..1cbfa03 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -1,6 +1,6 @@
 import pytest
 
-from bootpeg.pika.front import unescape
+from bootpeg.grammars.peg import unescape
 
 
 named_escapes = [