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
# 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