LALR(1) parsing in Python
Merges LR(1) states with identical cores, giving LR(1)-sized-down tables used by yacc.
- File
lalr_parsing.py, 212 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/lalr-parsing/. The authors are listed in prov/2JWXYX.jsonl.
"""LALR(1) parse tables and the shift-reduce driver that uses them.
A grammar is a list of productions (A, rhs), where rhs is a tuple of symbols.
The nonterminals are the left sides; every other symbol is a terminal. The
grammar is augmented with production 0, S' -> start, and the end marker "$".
The LR(0) automaton is built first; the LALR(1) lookahead sets of its kernel
items are then computed by propagation until nothing changes. The grammar
should have no useless symbols; then the LALR(1) automaton is the canonical
LR(1) automaton with its states merged by core. A parse tree is (A, children),
where each child is a tree or a terminal symbol.
"""
from dataclasses import dataclass, field
from typing import Optional, Sequence, Union
Tree = tuple[str, tuple[Union["Tree", str], ...]]
Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int] # (production index, dot position)
Action = tuple[str, int] # ("shift", state), ("reduce", production), ("accept", 0)
END = "$"
@dataclass
class LalrTable:
"""The LALR(1) automaton, its lookahead sets and its parse table."""
productions: list[Production]
nonterminals: set[str]
kernels: list[frozenset[Item]]
goto: dict[tuple[int, str], int]
lookahead: dict[tuple[int, Item], set[str]]
action: dict[tuple[int, str], set[Action]] = field(default_factory=dict)
def conflicts(self) -> list[tuple[int, str, set[Action]]]:
"""The table entries with more than one action."""
return [(q, a, acts) for (q, a), acts in sorted(self.action.items()) if len(acts) > 1]
def _first_sets(productions: Sequence[Production], nts: set[str]):
nullable: set[str] = set()
first: dict[str, set[str]] = {a: set() for a in nts}
changed = True
while changed:
changed = False
for a, rhs in productions:
for x in rhs:
add = first[x] if x in nts else {x}
if not add <= first[a]:
first[a] |= add
changed = True
if x not in nullable:
break
else:
if a not in nullable:
nullable.add(a)
changed = True
return nullable, first
def _first_of(seq: Sequence[str], tail: set[str], nts: set[str], nullable: set[str], first) -> set[str]:
"""FIRST(seq L) for a set L of terminals."""
out: set[str] = set()
for x in seq:
if x not in nts:
out.add(x)
return out
out |= first[x]
if x not in nullable:
return out
return out | tail
def _closure0(kernel: frozenset[Item], prods, by_lhs, nts) -> list[Item]:
items = list(kernel)
seen = set(items)
k = 0
while k < len(items):
p, dot = items[k]
k += 1
rhs = prods[p][1]
if dot < len(rhs) and rhs[dot] in nts:
for q in by_lhs[rhs[dot]]:
if (q, 0) not in seen:
seen.add((q, 0))
items.append((q, 0))
return items
def _closure1(kernel_la: dict[Item, set[str]], prods, by_lhs, nts, nullable, first) -> dict[Item, set[str]]:
"""LR(1) closure where each LR(0) item carries a set of lookaheads."""
la = {it: set(s) for it, s in kernel_la.items()}
work = list(la)
while work:
p, dot = work.pop()
rhs = prods[p][1]
if dot < len(rhs) and rhs[dot] in nts:
new = _first_of(rhs[dot + 1:], la[(p, dot)], nts, nullable, first)
for q in by_lhs[rhs[dot]]:
cur = la.setdefault((q, 0), set())
if not new <= cur:
cur |= new
work.append((q, 0))
return la
def lalr_table(productions: Sequence[Production], start: str) -> LalrTable:
"""Build the LALR(1) table of the grammar augmented with S' -> start."""
symbols = {a for a, _ in productions} | {x for _, r in productions for x in r}
if END in symbols:
raise ValueError("the end marker $ is a grammar symbol")
aug = start + "'"
while aug in symbols:
aug += "'"
prods: list[Production] = [(aug, (start,))] + [(a, tuple(r)) for a, r in productions]
nts = {a for a, _ in prods}
by_lhs: dict[str, list[int]] = {a: [] for a in nts}
for p, (a, _) in enumerate(prods):
by_lhs[a].append(p)
order: list[str] = []
for a, r in prods:
for x in (a,) + r:
if x not in order:
order.append(x)
nullable, first = _first_sets(prods, nts)
# L1: the LR(0) automaton, states numbered in breadth-first order.
kernels: list[frozenset[Item]] = [frozenset({(0, 0)})] # @line L1
index = {kernels[0]: 0}
goto: dict[tuple[int, str], int] = {}
q = 0
while q < len(kernels):
moves: dict[str, set[Item]] = {}
for p, dot in _closure0(kernels[q], prods, by_lhs, nts):
rhs = prods[p][1]
if dot < len(rhs):
moves.setdefault(rhs[dot], set()).add((p, dot + 1))
for x in sorted(moves, key=order.index):
k = frozenset(moves[x])
if k not in index:
index[k] = len(kernels)
kernels.append(k)
goto[(q, x)] = index[k]
q += 1
la: dict[tuple[int, Item], set[str]] = {(s, it): set() for s, k in enumerate(kernels) for it in k}
la[(0, (0, 0))] = {END} # @line L2
changed = True
while changed: # @line L3
changed = False
for s, k in enumerate(kernels): # @line L4
closed = _closure1({it: la[(s, it)] for it in k}, prods, by_lhs, nts, nullable, first) # @line L5
for (p, dot), look in closed.items(): # @line L6
rhs = prods[p][1]
if dot < len(rhs):
target = la[(goto[(s, rhs[dot])], (p, dot + 1))]
if not look <= target:
target |= look
changed = True # @line L7
table = LalrTable(prods, nts, kernels, goto, la)
for s, k in enumerate(kernels): # @line L8
closed = _closure1({it: la[(s, it)] for it in k}, prods, by_lhs, nts, nullable, first)
for (p, dot), look in closed.items():
rhs = prods[p][1]
if dot < len(rhs) and rhs[dot] not in nts: # @line L9
table.action.setdefault((s, rhs[dot]), set()).add(("shift", goto[(s, rhs[dot])]))
elif dot == len(rhs): # @line L10
for a in look:
act = ("accept", 0) if p == 0 else ("reduce", p)
table.action.setdefault((s, a), set()).add(act)
return table
def lalr_parse(table: LalrTable, w: Sequence[str]) -> Optional[Tree]:
"""Run the shift-reduce driver. Return the parse tree, or None if w is rejected.
Raises ValueError if the table has a conflict."""
if table.conflicts(): # @line L11
raise ValueError("the grammar is not LALR(1)")
prods = table.productions
stack = [0] # @line L12
values: list[Union[Tree, str]] = []
pos = 0
while True: # @line L13
a = w[pos] if pos < len(w) else END
acts = table.action.get((stack[-1], a))
if not acts:
return None # @line L17
kind, arg = next(iter(acts))
if kind == "shift": # @line L14
stack.append(arg)
values.append(a)
pos += 1
elif kind == "reduce": # @line L15
lhs, rhs = prods[arg]
m = len(rhs)
kids = tuple(values[len(values) - m:])
del stack[len(stack) - m:]
del values[len(values) - m:]
stack.append(table.goto[(stack[-1], lhs)])
values.append((lhs, kids))
else: # @line L16
tree = values[-1]
assert not isinstance(tree, str)
return tree
def lalr_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
"""Build the table and run the driver; raises ValueError if the grammar is not LALR(1)."""
return lalr_parse(lalr_table(productions, start), w) is not None
test_lalr_parsing.py 250 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lalr-parsing/. The authors are listed in prov/2JWXYX.jsonl.
import itertools
import random
import pytest
from lalr_parsing import END, lalr_parse, lalr_recognize, lalr_table
DRAGON = [
("S", ("L", "=", "R")), ("S", ("R",)),
("L", ("*", "R")), ("L", ("id",)),
("R", ("L",)),
]
def derives(productions, start, w):
"""Independent oracle: least fixpoint of 'A derives w[i:j]' over all spans."""
nts = {a for a, _ in productions}
n = len(w)
known = set()
def seq(rhs, i, j):
ends = {i}
for x in rhs:
nxt = set()
for p in ends:
if x in nts:
nxt |= {q for q in range(p, j + 1) if (x, p, q) in known}
elif p < j and w[p] == x:
nxt.add(p + 1)
ends = nxt
return j in ends
changed = True
while changed:
changed = False
for a, rhs in productions:
for i in range(n + 1):
for j in range(i, n + 1):
if (a, i, j) not in known and seq(rhs, i, j):
known.add((a, i, j))
changed = True
return (start, 0, n) in known
def canonical_lr1_merged(prods):
"""Independent oracle: the canonical LR(1) collection, built item by item with
single lookahead symbols, then merged by core. prods is the augmented list
(production 0 is S' -> S). Returns {core: {item: lookaheads}}."""
nts = {a for a, _ in prods}
def first_seq(seq):
# FIRST of a sequence by brute force over a fixed point of FIRST sets
out = set()
for x in seq:
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
NULL, FIRST = set(), {a: set() for a in nts}
for _ in range(len(prods) + 2):
for a, rhs in prods:
f, all_null = first_seq(rhs)
FIRST[a] |= f
if all_null:
NULL.add(a)
def closure(items):
items = set(items)
while True:
new = set()
for p, d, la in items:
rhs = prods[p][1]
if d < len(rhs) and rhs[d] in nts:
f, all_null = first_seq(rhs[d + 1:])
looks = f | ({la} if all_null else set())
for q, (b, _) in enumerate(prods):
if b == rhs[d]:
for t in looks:
new.add((q, 0, t))
if new <= items:
return frozenset(items)
items |= new
start = closure({(0, 0, END)})
states, todo = {start}, [start]
while todo:
st = todo.pop()
syms = {prods[p][1][d] for p, d, _ in st if d < len(prods[p][1])}
for x in syms:
nxt = closure({(p, d + 1, la) for p, d, la in st if d < len(prods[p][1]) and prods[p][1][d] == x})
if nxt not in states:
states.add(nxt)
todo.append(nxt)
merged = {}
for st in states:
core = frozenset((p, d) for p, d, _ in st)
m = merged.setdefault(core, {})
for p, d, la in st:
m.setdefault((p, d), set()).add(la)
return merged
def tree_ok(tree, productions, start, w):
prods = {(a, tuple(r)) for a, r in productions}
if tree[0] != start:
return False
leaves, stack = [], [tree]
while stack:
t = stack.pop()
if isinstance(t, str):
leaves.append(t)
continue
label, kids = t
if (label, tuple(k if isinstance(k, str) else k[0] for k in kids)) not in prods:
return False
stack.extend(reversed(kids))
return leaves == list(w)
def closure_items(table, s):
"""All LR(0) items of state s with their LALR(1) lookaheads, recomputed from the table."""
from lalr_parsing import _closure1, _first_sets
prods, nts = table.productions, table.nonterminals
by_lhs = {a: [p for p, (b, _) in enumerate(prods) if b == a] for a in nts}
nullable, first = _first_sets(prods, nts)
return _closure1({it: table.lookahead[(s, it)] for it in table.kernels[s]}, prods, by_lhs, nts, nullable, first)
def check_against_lr1(productions, start):
t = lalr_table(productions, start)
merged = canonical_lr1_merged(t.productions)
cores = {}
for s in range(len(t.kernels)):
items = closure_items(t, s)
cores[frozenset(items)] = {it: la for it, la in items.items()}
assert set(cores) == set(merged)
for core, items in merged.items():
assert cores[core] == items
return t
def reduced(prods, start):
"""True when every nonterminal derives some terminal string and is reachable."""
nts = {a for a, _ in prods}
gen, changed = set(), True
while changed:
changed = False
for a, r in prods:
if a not in gen and all(x in gen or x not in nts for x in r):
gen.add(a)
changed = True
reach, todo = {start}, [start]
while todo:
a = todo.pop()
for b, r in prods:
if b == a:
for x in r:
if x in nts and x not in reach:
reach.add(x)
todo.append(x)
return gen == nts and reach == nts
def random_grammar(rng):
nts = ["S", "A", "B"][: rng.randint(1, 3)]
syms = nts + ["a", "b", "c"]
prods = set()
for a in nts:
for _ in range(rng.randint(1, 3)):
if rng.random() < 0.15:
prods.add((a, ()))
else:
prods.add((a, tuple(rng.choice(syms) for _ in range(rng.randint(1, 3)))))
return sorted(prods)
def test_dragon_example():
t = lalr_table(DRAGON, "S")
assert len(t.kernels) == 10 and not t.conflicts()
# state 2 holds S -> L . = R and R -> L . ; the reduction is on $ only
s2 = t.goto[(0, "L")]
assert t.lookahead[(s2, (5, 1))] == {END}
assert t.action[(s2, "=")] == {("shift", t.goto[(s2, "=")])}
assert lalr_parse(t, ["id", "=", "*", "id"]) == (
"S", (("L", ("id",)), "=", ("R", (("L", ("*", ("R", (("L", ("id",)),)))),))))
assert lalr_parse(t, ["id", "="]) is None
check_against_lr1(DRAGON, "S")
def test_lr1_not_lalr1():
g = [
("S", ("a", "A", "d")), ("S", ("b", "B", "d")), ("S", ("a", "B", "e")), ("S", ("b", "A", "e")),
("A", ("c",)), ("B", ("c",)),
]
t = check_against_lr1(g, "S")
kinds = [sorted(k for k, _ in acts) for _, _, acts in t.conflicts()]
assert kinds == [["reduce", "reduce"], ["reduce", "reduce"]]
def test_ambiguous_has_conflict():
g = [("E", ("E", "+", "E")), ("E", ("a",))]
assert lalr_table(g, "E").conflicts()
with pytest.raises(ValueError):
lalr_recognize(g, "E", ["a"])
def test_left_and_right_recursion_and_empty():
left = [("S", ("S", "a")), ("S", ())]
right = [("S", ("a", "S")), ("S", ())]
for g in (left, right):
t = lalr_table(g, "S")
assert not t.conflicts()
for m in range(0, 50):
tree = lalr_parse(t, ["a"] * m)
assert tree is not None and tree_ok(tree, g, "S", ["a"] * m)
assert lalr_parse(t, ["b"]) is None
def test_long_input():
g = [("S", ("a", "S")), ("S", ("a",))]
w = ["a"] * 5000
assert tree_ok(lalr_parse(lalr_table(g, "S"), w), g, "S", w)
def test_random_against_oracles():
rng = random.Random(20261106)
words = [list(t) for m in range(5) for t in itertools.product("abc", repeat=m)]
lalr_count = checked = 0
for _ in range(2000):
g = random_grammar(rng)
if not reduced(g, "S"):
continue
t = check_against_lr1(g, "S")
if t.conflicts():
continue
lalr_count += 1
for w in [[]] + rng.sample(words, 8):
tree = lalr_parse(t, w)
assert (tree is not None) == derives(g, "S", w), (g, w)
if tree is not None:
assert tree_ok(tree, g, "S", w)
checked += 1
assert lalr_count >= 100 and checked >= 500
What the next level needs
- the tests did not run in this build