Algorithm Search⌘K

Packrat parsing in Python

Backtracking recursive descent with memoization, giving linear time for PEGs.

File
packrat_parsing.py, 243 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
packrat_parsing.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/packrat-parsing/. The authors are listed in prov/1PYY5B.jsonl.

"""Packrat parsing of parsing expression grammars, without recursion.

A grammar is a dict that maps each nonterminal name to one parsing expression.
Expressions are tuples:

    ("lit", a)          the token a
    ("any",)            any one token
    ("eps",)            the empty match
    ("ref", A)          the nonterminal A
    ("seq", e1, ..., ek)
    ("alt", e1, ..., ek)   ordered choice: the first alternative that succeeds
    ("star", e)         greedy repetition
    ("not", e)          succeeds, consuming nothing, exactly when e fails
    ("and", e)          succeeds, consuming nothing, exactly when e succeeds

Every subexpression is compiled to a number, and the result of each pair
(expression, position) is computed once and kept in a table. The evaluation uses
an explicit stack, so deep inputs do not hit the recursion limit.

A parse tree is a pair (A, children) for a nonterminal node and a plain string for
a token leaf. The children of a node are the tokens and nonterminal nodes matched
by the body of its rule, in order; predicates contribute nothing.
"""

Expr = tuple
Tree = tuple[str, list] | str

FAIL = -1
LIT, ANY, EPS, REF, SEQ, ALT, STAR, NOT, AND = range(9)
_KINDS = {"lit": LIT, "any": ANY, "eps": EPS, "ref": REF, "seq": SEQ,
          "alt": ALT, "star": STAR, "not": NOT, "and": AND}


class ParseError(Exception):
    """The input is not in the language; position is the farthest failure.

    A failure is a token test or a not-predicate that fails.
    """

    def __init__(self, position: int, message: str) -> None:
        super().__init__(f"at token {position}: {message}")
        self.position = position


class Peg:
    """A grammar compiled to arrays indexed by expression number."""

    def __init__(self, grammar: dict[str, Expr]) -> None:
        self.kind: list[int] = []
        self.arg: list = []        # the token, or the rule name for "ref"
        self.kids: list[tuple[int, ...]] = []
        self.rule: dict[str, int] = {}
        todo: list[tuple[Expr, int]] = []
        for name, body in grammar.items():
            self.rule[name] = self._new(body, todo)
        while todo:
            e, k = todo.pop()
            self.kids[k] = tuple(self._new(c, todo) for c in e[1:])
        for k, kd in enumerate(self.kind):
            if kd == REF and self.arg[k] not in self.rule:
                raise ValueError(f"no rule for {self.arg[k]}")

    def _new(self, e: Expr, todo: list) -> int:
        k = len(self.kind)
        kd = _KINDS[e[0]]
        self.kind.append(kd)
        self.arg.append(e[1] if kd in (LIT, REF) else None)
        self.kids.append(())
        if kd in (SEQ, ALT, STAR, NOT, AND):
            todo.append((e, k))
        return k


class Packrat:
    """One run of the parser on one input; memo holds every computed result."""

    def __init__(self, peg: Peg, tokens: list[str]) -> None:
        self.peg = peg
        self.tokens = tokens
        self.n = len(tokens)
        self.memo: dict[int, int] = {}      # key e * (n + 1) + i -> end or FAIL
        self.choice: dict[int, int] = {}    # key of an "alt" -> winning alternative
        self.far = 0                        # farthest position of a failed token test
        self.evaluations = 0                # pairs (e, i) computed, not looked up

    def eval(self, root: int, pos: int) -> int:
        """The end of the match of expression root at pos, or FAIL."""
        peg, toks, n, memo = self.peg, self.tokens, self.n, self.memo
        kind, arg, kids = peg.kind, peg.arg, peg.kids
        width = n + 1
        active: set[int] = set()
        stack: list[list[int]] = []         # frames [e, i, state]
        req: tuple[int, int] | None = (root, pos)
        ret = FAIL
        while True:
            if req is not None:
                e, i = req
                req = None
                key = e * width + i
                r = memo.get(key)
                if r is not None:  # @line L5
                    ret = r
                elif key in active:  # @line L6
                    raise ValueError("left recursion: the grammar is not well-formed")
                else:
                    self.evaluations += 1
                    kd = kind[e]
                    if kd == LIT or kd == ANY:  # @line L8
                        if i < n and (kd == ANY or toks[i] == arg[e]):
                            r = i + 1
                        else:
                            r = FAIL
                            self.far = max(self.far, i)
                        memo[key] = r
                        ret = r
                    elif kd == EPS:
                        memo[key] = i
                        ret = i
                    else:  # @line L7
                        active.add(key)
                        stack.append([e, i, 0])
                        req = (kids[e][0] if kd != REF else peg.rule[arg[e]], i)
                        continue
            if not stack:
                return ret
            frame = stack[-1]
            e, i, state = frame
            kd = kind[e]
            done = True
            if kd == REF:  # @line L9
                r = ret
            elif kd == SEQ:  # @line L10
                if ret == FAIL or state + 1 == len(kids[e]):
                    r = ret
                else:
                    frame[2] = state + 1
                    req = (kids[e][state + 1], ret)
                    done = False
            elif kd == ALT:  # @line L11
                if ret != FAIL:
                    self.choice[e * width + i] = state
                    r = ret
                elif state + 1 == len(kids[e]):
                    r = FAIL
                else:
                    frame[2] = state + 1
                    req = (kids[e][state + 1], i)
                    done = False
            elif kd == STAR:  # @line L12
                if state == 1:
                    r = ret
                elif ret == FAIL:
                    r = i
                elif ret == i:
                    raise ValueError("repetition of an empty match: the grammar is not well-formed")
                else:
                    frame[2] = 1
                    req = (e, ret)
                    done = False
            else:  # @line L13
                r = i if (ret == FAIL) == (kd == NOT) else FAIL
                if r == FAIL and kd == NOT:
                    self.far = max(self.far, i)
            if done:
                key = e * width + i
                memo[key] = r  # @line L14
                active.discard(key)
                stack.pop()
                ret = r

    def tree(self, name: str, pos: int) -> Tree:
        """The parse tree of the successful match of the rule name at pos.

        A node for the same rule at the same position is built once and shared,
        so the tree is returned as a graph with O(|G| (n + 1)) distinct nodes even
        when, written out, it is much larger (empty matches can nest deeply).
        """
        peg, toks, memo, width = self.peg, self.tokens, self.memo, self.n + 1
        kind, kids = peg.kind, peg.kids
        root: tuple[str, list] = (name, [])
        built: dict[int, tuple[str, list]] = {peg.rule[name] * width + pos: root}
        work: list[tuple[int, int, list]] = [(peg.rule[name], pos, root[1])]
        while work:
            e, i, out = work.pop()
            kd = kind[e]
            if kd == LIT or kd == ANY:  # @line L15
                out.append(toks[i])
            elif kd == REF:  # @line L16
                body = peg.rule[peg.arg[e]]
                node = built.get(body * width + i)
                if node is None:
                    node = (peg.arg[e], [])
                    built[body * width + i] = node
                    work.append((body, i, node[1]))
                out.append(node)
            elif kd == SEQ:  # @line L17
                parts = []
                for c in kids[e]:
                    parts.append((c, i, out))
                    i = memo[c * width + i]
                work.extend(reversed(parts))
            elif kd == ALT:
                work.append((kids[e][self.choice[e * width + i]], i, out))
            elif kd == STAR:
                c = kids[e][0]
                j = memo[c * width + i]
                if j != FAIL:
                    work.append((e, j, out))
                    work.append((c, i, out))
            # @line L18: eps, not and and add nothing
        return root


def packrat_parse(grammar: dict[str, Expr], start: str, tokens: list[str],
                  stats: dict | None = None) -> Tree:
    """Match the rule start against all of tokens and return the parse tree.

    Raises ParseError at the farthest failed token test when the rule fails or
    leaves input unread, and ValueError when the run meets left recursion or a
    repetition of an empty match.
    """
    peg = Peg(grammar)
    run = Packrat(peg, list(tokens))  # @line L1
    end = run.eval(peg.rule[start], 0)  # @line L2
    if stats is not None:
        stats["evaluations"] = run.evaluations
        stats["expressions"] = len(peg.kind)
    if end == FAIL:  # @line L3
        raise ParseError(run.far, "no match")
    if end < run.n:
        raise ParseError(max(run.far, end), "input left over")
    return run.tree(start, 0)  # @line L4


def packrat_match(grammar: dict[str, Expr], start: str, tokens: list[str]) -> int | None:
    """The end of the match of start at position 0, or None if it fails."""
    peg = Peg(grammar)
    run = Packrat(peg, list(tokens))
    end = run.eval(peg.rule[start], 0)
    return None if end == FAIL else end
test_packrat_parsing.py 323 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/packrat-parsing/. The authors are listed in prov/1PYY5B.jsonl.

import itertools
import random

import pytest

from packrat_parsing import ParseError, packrat_match, packrat_parse


def lit(a):
    return ("lit", a)


def ref(a):
    return ("ref", a)


def seq(*es):
    return ("seq",) + es


def alt(*es):
    return ("alt",) + es


def star(e):
    return ("star", e)


def neg(e):
    return ("not", e)


def amp(e):
    return ("and", e)


EPS = ("eps",)
ANY = ("any",)


class Diverges(Exception):
    pass


class OutOfFuel(Exception):
    pass


def oracle(grammar, start, tokens, fuel=200000):
    """The PEG semantics read off directly, by plain recursion and without any memo.

    Returns ("ok", end, children) or ("fail",), and the farthest failed token test.
    Raises Diverges when the semantics has no result (a call repeats itself, or a
    repetition matches the empty string).
    """
    n = len(tokens)
    far = [0]
    left = [fuel]
    stack = []

    def m(e, i):
        left[0] -= 1
        if left[0] < 0:
            raise OutOfFuel
        key = (id(e), i)
        if key in stack:
            raise Diverges
        stack.append(key)
        try:
            return step(e, i)
        finally:
            stack.pop()

    def step(e, i):
        k = e[0]
        if k == "lit" or k == "any":
            if i < n and (k == "any" or tokens[i] == e[1]):
                return i + 1, [tokens[i]]
            far[0] = max(far[0], i)
            return None
        if k == "eps":
            return i, []
        if k == "ref":
            r = m(grammar[e[1]], i)
            return None if r is None else (r[0], [(e[1], r[1])])
        if k == "seq":
            kids = []
            for c in e[1:]:
                r = m(c, i)
                if r is None:
                    return None
                i = r[0]
                kids += r[1]
            return i, kids
        if k == "alt":
            for c in e[1:]:
                r = m(c, i)
                if r is not None:
                    return r
            return None
        if k == "star":
            kids = []
            while True:
                r = m(e[1], i)
                if r is None:
                    return i, kids
                if r[0] == i:
                    raise Diverges
                i = r[0]
                kids += r[1]
        r = m(e[1], i)
        if (r is None) == (k == "not"):
            return i, []
        if k == "not":
            far[0] = max(far[0], i)
        return None

    r = m(grammar[start], 0)
    return r, far[0]


def expected(grammar, start, tokens):
    """The tree, or ("error", position), as packrat_parse should give it."""
    r, far = oracle(grammar, start, tokens)
    if r is None:
        return ("error", far)
    if r[0] < len(tokens):
        return ("error", max(far, r[0]))
    return (start, r[1])


def run(grammar, start, tokens):
    try:
        return packrat_parse(grammar, start, tokens)
    except ParseError as e:
        return ("error", e.position)


def leaves(t):
    out, work = [], [t]
    while work:
        x = work.pop()
        if isinstance(x, str):
            out.append(x)
        else:
            work.extend(reversed(x[1]))
    return out


# Ford's grammar for a^n b^n c^n, with the full-match check.
ABC = {
    "S": seq(amp(seq(ref("A"), lit("c"))), lit("a"), star(lit("a")), ref("B"), neg(ANY)),
    "A": seq(lit("a"), alt(ref("A"), EPS), lit("b")),
    "B": seq(lit("b"), alt(ref("B"), EPS), lit("c")),
}

# Expressions with repetition instead of left recursion.
EXPR = {
    "E": seq(ref("T"), star(seq(lit("+"), ref("T")))),
    "T": seq(ref("F"), star(seq(lit("*"), ref("F")))),
    "F": alt(seq(lit("("), ref("E"), lit(")")), lit("x")),
    "Top": seq(ref("E"), neg(ANY)),
}


def test_expression_tree():
    t = packrat_parse(EXPR, "Top", list("x+x*x"))
    assert t == ("Top", [("E", [("T", [("F", ["x"])]), "+",
                                ("T", [("F", ["x"]), "*", ("F", ["x"])])])])
    assert leaves(t) == list("x+x*x")


def test_expression_errors():
    for s, pos in [("x+", 2), ("x+*x", 2), ("(x", 2), ("x)", 1), ("", 0), ("xx", 1)]:
        with pytest.raises(ParseError) as err:
            packrat_parse(EXPR, "Top", list(s))
        assert err.value.position == pos, s
        assert ("error", pos) == expected(EXPR, "Top", list(s))


def test_anbncn():
    for m in range(0, 11):
        for w in itertools.product("abc", repeat=m):
            w = list(w)
            k = len(w) // 3
            want = k >= 1 and w == ["a"] * k + ["b"] * k + ["c"] * k
            assert (run(ABC, "S", w)[0] != "error") == want, w


def test_ordered_choice_and_greed():
    g = {"S": seq(alt(lit("a"), seq(lit("a"), lit("b"))), neg(ANY))}
    assert run(g, "S", ["a"])[0] == "S"
    assert run(g, "S", ["a", "b"]) == ("error", 1)
    g = {"S": seq(star(lit("a")), lit("a"))}
    for m in range(6):
        assert packrat_match(g, "S", ["a"] * m) is None


def test_powers_of_two():
    g = {"S": alt(seq(lit("a"), ref("S"), lit("a")), lit("a")), "Top": seq(ref("S"), neg(ANY))}
    ok = [m for m in range(1, 70) if run(g, "Top", ["a"] * m)[0] == "Top"]
    assert ok == [1, 3, 7, 15, 31, 63]


def test_example_of_the_page():
    g = {"S": alt(seq(ref("A"), lit("b")), seq(ref("A"), lit("c"))),
         "A": alt(seq(lit("a"), ref("A")), lit("a"))}
    stats = {}
    t = packrat_parse(g, "S", list("aac"), stats)
    assert t == ("S", [("A", ["a", ("A", ["a"])]), "c"])
    assert stats["evaluations"] == 20 and stats["expressions"] == 12


def test_exponential_backtracking_becomes_linear():
    for k in range(1, 16):
        g = {"A0": lit("a")}
        for j in range(1, k + 1):
            g[f"A{j}"] = alt(seq(ref(f"A{j - 1}"), lit("x")), seq(ref(f"A{j - 1}"), lit("y")))
        w = ["a"] + ["y"] * k
        stats = {}
        t = packrat_parse(g, f"A{k}", w, stats)
        assert leaves(t) == w
        assert stats["evaluations"] <= stats["expressions"] * (len(w) + 1)
        if k <= 10:
            assert expected(g, f"A{k}", w) == t


def test_left_recursion_and_empty_star_are_reported():
    g = {"E": alt(seq(ref("E"), lit("+"), lit("x")), lit("x"))}
    with pytest.raises(ValueError):
        packrat_parse(g, "E", list("x+x"))
    g = {"S": star(alt(lit("a"), EPS))}
    with pytest.raises(ValueError):
        packrat_parse(g, "S", list("aa"))
    with pytest.raises(ValueError):
        packrat_parse({"S": ref("T")}, "S", [])


def test_deep_input_without_recursion():
    n = 100000
    g = {"S": alt(seq(lit("("), ref("S"), lit(")")), EPS), "Top": seq(ref("S"), neg(ANY))}
    t = packrat_parse(g, "Top", ["("] * n + [")"] * n)
    depth, x = 0, t[1][0]
    while x[1]:
        depth += 1
        x = x[1][1]
    assert depth == n
    with pytest.raises(ParseError) as err:
        packrat_parse(g, "Top", ["("] * n + [")"] * (n - 1))
    assert err.value.position == 2 * n - 1
    g = {"L": star(seq(lit("a"), ref("I"))), "I": alt(lit("b"), EPS)}
    t = packrat_parse(g, "L", ["a", "b"] * n)
    assert len(t[1]) == 2 * n


def test_shared_empty_subtrees():
    g = {"B0": EPS, "S": star(seq(lit("a"), ref("B40")))}
    for j in range(1, 41):
        g[f"B{j}"] = seq(ref(f"B{j - 1}"), ref(f"B{j - 1}"))
    stats = {}
    t = packrat_parse(g, "S", ["a"] * 50, stats)
    seen, work = set(), [t]
    while work:
        x = work.pop()
        if not isinstance(x, str) and id(x) not in seen:
            seen.add(id(x))
            work.extend(x[1])
    assert len(t[1]) == 100 and len(seen) <= stats["expressions"] * 51


def random_expr(rng, names, depth):
    r = rng.random()
    if depth == 0 or r < 0.3:
        c = rng.random()
        if c < 0.55:
            return lit(rng.choice("abc"))
        if c < 0.8:
            return ref(rng.choice(names))
        if c < 0.9:
            return EPS
        return ANY
    k = rng.choice(["seq", "seq", "alt", "alt", "star", "not", "and"])
    if k in ("seq", "alt"):
        return (k,) + tuple(random_expr(rng, names, depth - 1) for _ in range(rng.randint(1, 3)))
    return (k, random_expr(rng, names, depth - 1))


def test_random_against_semantics():
    rng = random.Random(20021004)
    inputs = [list(w) for m in range(5) for w in itertools.product("abc", repeat=m)]
    checked = accepted = diverged = 0
    for _ in range(700):
        names = [f"N{j}" for j in range(rng.randint(1, 4))]
        g = {a: random_expr(rng, names, 3) for a in names}
        if rng.random() < 0.5:
            g["Top"] = seq(ref(names[0]), neg(ANY))
            start = "Top"
        else:
            start = names[0]
        ws = inputs + [[rng.choice("abc") for _ in range(rng.randint(5, 9))] for _ in range(10)]
        for w in ws:
            try:
                want = expected(g, start, w)
            except OutOfFuel:
                continue
            except Diverges:
                with pytest.raises(ValueError):
                    packrat_parse(g, start, w)
                diverged += 1
                continue
            got = run(g, start, w)
            assert got == want, (g, start, w)
            if got[0] != "error":
                assert leaves(got) == w
                accepted += 1
                stats = {}
                packrat_parse(g, start, w, stats)
                assert stats["evaluations"] <= stats["expressions"] * (len(w) + 1)
            checked += 1
    assert checked >= 500 and accepted >= 500 and diverged >= 50

What the next level needs

  • the tests did not run in this build

Read the full page about Packrat parsing