Repository: maxfischer2781/bootpeg

Issue:
Optimise/Elide Anything matches
The ``Anything``/``.`` is conceptually similar to ``Nothing``/``ε`` in that it matches (almost) everywhere. However, only the latter is matched lazily.

Optimise ``.`` similar to ``ε`` so that it is not matched eagerly, and everything depending on ``.`` is always triggered. This might need extending the concept of ``Clause.maybe_zero``.

Discussion:

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 @@ The default bootpeg grammar
 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 @@ class Domain(Protocol):
     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 @@ math_expressions = [
     ("-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 @@ import pytest
 
 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 @@ import pytest
 
 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 @@ sys.setrecursionlimit(30000)
 # 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 @@ EndOfFile <- !.
 
 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 = [