LR parse table in Python
Action and goto tables that drive a shift-reduce LR parser.
- File
lr_parse_table.py, 215 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/lr-parse-table/. The authors are listed in prov/B3QKMP.jsonl.
"""LR parse tables: ACTION and GOTO, filled by the LR(0), SLR(1) or LR(1) rules.
A grammar is a list of productions (A, rhs) with rhs a tuple of symbols. The
nonterminals are the left sides; every other symbol is a terminal. A parse tree is
a pair (A, children) for a nonterminal node, and a plain string for a token leaf.
The grammar is augmented with S' -> S, and the end marker $ ends the input.
"""
END = "$"
AUG = ("S'",) # the new start symbol; a tuple, so it cannot clash with a name
Production = tuple[str, tuple[str, ...]]
Tree = tuple[str, list] | str
Action = tuple # ("shift", state) | ("reduce", production) | ("accept",)
class ParseError(Exception):
"""The input is not in the language; position is the index of the bad token."""
def __init__(self, position: int, message: str) -> None:
super().__init__(f"at token {position}: {message}")
self.position = position
class LRParseTable:
"""ACTION[q][a] and GOTO[q][A] for the states q of an LR automaton.
action[q] maps a terminal or $ to one action; goto[q] maps a nonterminal to a
state; conflicts lists (q, a, actions) for every cell that got two actions.
"""
def __init__(self, prods: list, n_states: int) -> None:
self.prods = prods
self.action: list[dict[str, Action]] = [{} for _ in range(n_states)]
self.goto: list[dict[str, int]] = [{} for _ in range(n_states)]
self.conflicts: list[tuple[int, str, set]] = []
def set_action(self, q: int, a: str, act: Action) -> None:
old = self.action[q].get(a)
if old is None or old == act: # @line L7
self.action[q][a] = act
else: # @line L8
self.conflicts.append((q, a, {old, act}))
def lookup(self, q: int, a: str) -> Action | None:
return self.action[q].get(a) # @line L9
def _productive(productions: list[Production]) -> list[Production]:
nts = {a for a, _ in productions}
good: set[str] = set()
changed = True
while changed:
changed = False
for a, rhs in productions:
if a not in good and all(x in good or x not in nts for x in rhs):
good.add(a)
changed = True
return [(a, r) for a, r in productions
if a in good and all(x in good or x not in nts for x in r)]
def _first_follow(prods, nts):
null: set = set()
first: dict = {a: set() for a in nts}
follow: dict = {a: set() for a in nts}
follow[AUG].add(END)
def first_of(alpha):
out: set = set()
for x in alpha:
if x not in nts:
out.add(x)
return out, False
out |= first[x]
if x not in null:
return out, False
return out, True
changed = True
while changed:
changed = False
for a, rhs in prods:
f, nul = first_of(rhs)
if nul and a not in null:
null.add(a)
changed = True
if not f <= first[a]:
first[a] |= f
changed = True
for i, x in enumerate(rhs):
if x in nts:
g, rest = first_of(rhs[i + 1:])
if rest:
g = g | follow[a]
if not g <= follow[x]:
follow[x] |= g
changed = True
return first_of, follow
def build_table(productions: list[Production], start: str, kind: str = "slr") -> LRParseTable:
"""Build the LR automaton and fill ACTION and GOTO.
kind is "lr0" (reduce on every terminal), "slr" (reduce on FOLLOW of the left
side) or "lr1" (canonical LR(1) items, reduce on the item's lookahead).
Productions that use a nonterminal deriving no terminal string are removed first.
"""
if kind not in ("lr0", "slr", "lr1"):
raise ValueError(f"unknown kind {kind!r}")
prods = [(AUG, (start,))] + _productive(productions)
nts = {a for a, _ in productions} | {start, AUG}
terms = sorted({x for _, r in prods for x in r if x not in nts}) + [END]
first_of, follow = _first_follow(prods, nts)
by_lhs: dict = {}
for p, (a, _) in enumerate(prods):
by_lhs.setdefault(a, []).append(p)
def closure(kernel):
items = set(kernel)
todo = list(kernel)
while todo:
it = todo.pop()
p, d = it[0], it[1]
rhs = prods[p][1]
if d < len(rhs) and rhs[d] in nts:
if kind == "lr1":
f, nul = first_of(rhs[d + 1:])
las = f | {it[2]} if nul else f
else:
las = (None,)
for q in by_lhs.get(rhs[d], ()):
for la in las:
new = (q, 0, la) if kind == "lr1" else (q, 0)
if new not in items:
items.add(new)
todo.append(new)
return frozenset(items)
start_item = (0, 0, END) if kind == "lr1" else (0, 0)
states = [closure([start_item])]
index = {states[0]: 0}
trans: dict = {}
q = 0
while q < len(states):
moves: dict = {}
for it in states[q]:
rhs = prods[it[0]][1]
if it[1] < len(rhs):
moves.setdefault(rhs[it[1]], []).append((it[0], it[1] + 1) + tuple(it[2:]))
for x, kernel in moves.items():
j = closure(kernel)
if j not in index:
index[j] = len(states)
states.append(j)
trans[q, x] = index[j]
q += 1
table = LRParseTable(prods, len(states))
for q, items in enumerate(states):
for it in sorted(items, key=repr): # @line L1
p, d = it[0], it[1]
a, rhs = prods[p]
if d < len(rhs) and rhs[d] not in nts: # @line L2
table.set_action(q, rhs[d], ("shift", trans[q, rhs[d]]))
elif d == len(rhs) and p == 0: # @line L3
table.set_action(q, END, ("accept",))
elif d == len(rhs): # @line L4
if kind == "lr0":
las = terms
elif kind == "slr":
las = sorted(follow[a])
else:
las = [it[2]]
for b in las: # @line L5
table.set_action(q, b, ("reduce", p))
for (q, x), j in trans.items():
if x in nts:
table.goto[q][x] = j # @line L6
return table
def lr_parse(table: LRParseTable, tokens: list[str]) -> Tree:
"""Run the shift-reduce driver on tokens. Requires a table without conflicts.
Raises ParseError at the first token whose cell is empty.
"""
if table.conflicts:
raise ValueError("the table has conflicts")
prods = table.prods
w = list(tokens) + [END]
states = [0]
trees: list = []
i = 0
while True:
act = table.lookup(states[-1], w[i]) # @line L10
if act is None or (w[i] == END and i != len(w) - 1): # @line L11
raise ParseError(i, f"unexpected {w[i]!r}")
if act[0] == "shift": # @line L12
states.append(act[1])
trees.append(w[i])
i += 1
elif act[0] == "reduce": # @line L13
a, rhs = prods[act[1]]
k = len(rhs)
node = (a, trees[len(trees) - k:])
del trees[len(trees) - k:]
del states[len(states) - k:]
states.append(table.goto[states[-1]][a])
trees.append(node)
else: # @line L14
return trees[0]
test_lr_parse_table.py 193 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lr-parse-table/. The authors are listed in prov/B3QKMP.jsonl.
import itertools
import random
import pytest
from lr_parse_table import END, ParseError, build_table, lr_parse
EXPR = [
("E", ("E", "+", "T")),
("E", ("T",)),
("T", ("T", "*", "F")),
("T", ("F",)),
("F", ("(", "E", ")")),
("F", ("id",)),
]
def lang_upto(prods, max_len):
"""Oracle: for each nonterminal, the strings of length <= max_len that it derives."""
nts = {a for a, _ in prods}
lang = {a: set() for a in nts}
def of(x):
return lang[x] if x in nts else {(x,)}
changed = True
while changed:
changed = False
for a, rhs in prods:
cur = {()}
for x in rhs:
cur = {u + v for u in cur for v in of(x) if len(u) + len(v) <= max_len}
if not cur:
break
if not cur <= lang[a]:
lang[a] |= cur
changed = True
return lang
def prefix_lang(prods, max_len):
"""Oracle: for each nonterminal, the prefixes of length <= max_len of the strings it derives.
Every nonterminal must be productive, so that each symbol after a prefix can be completed.
"""
nts = {a for a, _ in prods}
full = lang_upto(prods, max_len)
pre = {a: set() for a in nts}
def full_of(x):
return full[x] if x in nts else {(x,)}
def pre_of(x):
return pre[x] if x in nts else {(), (x,)}
changed = True
while changed:
changed = False
for a, rhs in prods:
new = {()}
cur = {()}
for x in rhs:
new |= {u + v for u in cur for v in pre_of(x) if len(u) + len(v) <= max_len}
cur = {u + v for u in cur for v in full_of(x) if len(u) + len(v) <= max_len}
if not new <= pre[a]:
pre[a] |= new
changed = True
return pre
def productive_only(prods):
"""Remove the productions that use a nonterminal deriving no terminal string."""
nts = {a for a, _ in prods}
good = set()
changed = True
while changed:
changed = False
for a, rhs in prods:
if a not in good and all(x in good or x not in nts for x in rhs):
good.add(a)
changed = True
return [(a, r) for a, r in prods if a in good and all(x in good or x not in nts for x in r)]
def check_tree(prods, start, tree, tokens):
"""Iteratively check that tree is a parse tree of tokens with root start."""
nts = {a for a, _ in prods}
rules = set(prods)
assert isinstance(tree, tuple) and tree[0] == start
leaves = []
todo = [tree]
while todo:
t = todo.pop()
if isinstance(t, str):
assert t not in nts
leaves.append(t)
continue
a, kids = t
labels = tuple(k if isinstance(k, str) else k[0] for k in kids)
assert (a, labels) in rules
todo.extend(reversed(kids))
assert leaves == list(tokens)
def random_grammar(rng, n_nt, terms, eps_prob=0.2):
names = ["S", "A", "B", "C"][:n_nt]
prods = []
for a in names:
for _ in range(rng.randint(1, 3)):
k = 0 if rng.random() < eps_prob else rng.randint(1, 3)
prods.append((a, tuple(rng.choice(names + terms) for _ in range(k))))
return prods
def test_expression_grammar_tables():
with pytest.raises(ValueError):
build_table(EXPR, "E", "nope")
t0 = build_table(EXPR, "E", "lr0")
assert len(t0.action) == 12 and len(t0.conflicts) > 0
slr = build_table(EXPR, "E", "slr")
assert len(slr.action) == 12 and slr.conflicts == []
lr1 = build_table(EXPR, "E", "lr1")
assert len(lr1.action) == 22 and lr1.conflicts == []
n_action = sum(len(r) for r in slr.action)
n_goto = sum(len(r) for r in slr.goto)
assert (n_action, n_goto) == (36, 9)
toks = ["id", "+", "id", "*", "id"]
for t in (slr, lr1):
tree = lr_parse(t, toks)
check_tree(EXPR, "E", tree, toks)
assert tree[1][1] == "+"
for bad, pos in ((["id", "id"], 1), (["+"], 0), (["id", "+"], 2), ([], 0),
(["(", "id", "$", ")"], 2)):
with pytest.raises(ParseError) as e:
lr_parse(t, bad)
assert e.value.position == pos
with pytest.raises(ValueError):
lr_parse(t0, toks)
def test_lookahead_classes():
# LR(1) but not SLR(1): the textbook L = R grammar
g = [("S", ("L", "=", "R")), ("S", ("R",)), ("L", ("*", "R")), ("L", ("id",)), ("R", ("L",))]
assert build_table(g, "S", "slr").conflicts
assert build_table(g, "S", "lr1").conflicts == []
# empty productions and the empty input
g2 = [("S", ("a", "S", "b")), ("S", ())]
assert build_table(g2, "S", "lr0").conflicts
t = build_table(g2, "S", "slr")
assert lr_parse(t, []) == ("S", [])
check_tree(g2, "S", lr_parse(t, ["a", "a", "b", "b"]), ["a", "a", "b", "b"])
def test_deep_input_has_no_recursion_limit():
t = build_table(EXPR, "E", "slr")
n = 100000
toks = ["("] * n + ["id"] + [")"] * n
check_tree(EXPR, "E", lr_parse(t, toks), toks)
def test_random_against_brute_force():
rng = random.Random(7741)
terms = ["a", "b"]
counts = {"lr0": 0, "slr": 0, "lr1": 0}
for _ in range(2500):
prods = productive_only(random_grammar(rng, rng.randint(1, 4), terms))
if not any(a == "S" for a, _ in prods):
continue
tables = {k: build_table(prods, "S", k) for k in counts}
ok = {k: not t.conflicts for k, t in tables.items()}
# each method accepts at least the grammars of the weaker one
assert ok["slr"] or not ok["lr0"]
assert ok["lr1"] or not ok["slr"]
bound = 6
lang = lang_upto(prods, bound)
prefixes = prefix_lang(prods, bound + 1)["S"]
for k, t in tables.items():
if not ok[k]:
continue
counts[k] += 1
for m in range(bound + 1):
for w in itertools.product(terms, repeat=m):
if w in lang["S"]:
check_tree(prods, "S", lr_parse(t, list(w)), w)
else:
with pytest.raises(ParseError) as e:
lr_parse(t, list(w))
i = e.value.position
assert w[:i] in prefixes, (k, prods, w, i)
assert w[:i + 1] not in prefixes or i == len(w), (k, prods, w, i)
assert min(counts.values()) >= 500, counts
What the next level needs
- the tests did not run in this build