Canonical LR(1) parsing in Python
The most powerful LR parser with one token of lookahead, using full LR(1) item sets.
- File
canonical_lr_parsing.py, 170 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/canonical-lr-parsing/. The authors are listed in prov/12M6QZ.jsonl.
from dataclasses import dataclass, field
from collections.abc import Sequence
END = "$" # the end marker; it must not be a terminal of the grammar
START = "S'" # the new start symbol of the augmented grammar
Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int, str] # (production index, dot position, lookahead)
@dataclass
class LRTables:
"""The canonical LR(1) automaton and its parse tables.
productions[0] is S' -> S; productions[k] for k >= 1 is the (k-1)-th input production.
action[(state, terminal)] is ("shift", t), ("reduce", k) or ("accept",).
goto[(state, nonterminal)] is a state. conflicts lists each entry that got two actions.
"""
productions: list[Production]
nonterminals: set[str]
states: list[frozenset[Item]]
action: dict[tuple[int, str], tuple] = field(default_factory=dict)
goto: dict[tuple[int, str], int] = field(default_factory=dict)
conflicts: list[tuple[tuple[int, str], tuple, tuple]] = field(default_factory=list)
def first_sets(productions: Sequence[Production], nts: set[str]) -> tuple[dict[str, set[str]], set[str]]:
"""FIRST of each nonterminal and the set of nullable nonterminals, by a fixpoint.
Cost: O(|G| * |V| * |Sigma|) set work in the worst case.
"""
first: dict[str, set[str]] = {a: set() for a in nts}
nullable: set[str] = set()
changed = True
while changed:
changed = False
for a, rhs in productions:
all_nullable = True
for x in rhs:
f = first[x] if x in nts else {x}
if not f <= first[a]:
first[a] |= f
changed = True
if x not in nullable:
all_nullable = False
break
if all_nullable and a not in nullable:
nullable.add(a)
changed = True
return first, nullable
def first_of(seq: Sequence[str], la: str, first: dict[str, set[str]], nullable: set[str]) -> set[str]:
"""FIRST(seq la): the terminals that can begin a string derived from seq followed by la."""
out: set[str] = set()
for x in seq:
if x in first:
out |= first[x]
if x not in nullable:
return out
else:
out.add(x)
return out
out.add(la)
return out
def closure(items, productions, first, nullable) -> frozenset[Item]:
"""CLOSURE of a set of LR(1) items, with an explicit work list."""
result = set(items) # @line L14
work = list(items)
while work: # @line L16
p, d, la = work.pop()
rhs = productions[p][1]
if d < len(rhs) and rhs[d] in first: # @line L15
for b in first_of(rhs[d + 1:], la, first, nullable):
for q, (lhs, _) in enumerate(productions):
if lhs == rhs[d] and (q, 0, b) not in result:
result.add((q, 0, b))
work.append((q, 0, b))
return frozenset(result)
def goto_set(items, x, productions, first, nullable) -> frozenset[Item]:
"""GOTO(I, X): move the dot over X in each item that allows it, then close."""
kernel = {(p, d + 1, la) for p, d, la in items
if d < len(productions[p][1]) and productions[p][1][d] == x}
return closure(kernel, productions, first, nullable) # @line L17
def build_tables(start: str, grammar: Sequence[Production]) -> LRTables:
"""Build the canonical LR(1) tables of a context-free grammar.
grammar is a list of productions (A, (X1, ..., Xk)); the nonterminals are the left
sides, every other symbol is a terminal. A conflict keeps the first action and is
listed in tables.conflicts; the grammar is LR(1) when the list is empty (for a
grammar without useless symbols). Cost: polynomial in the number of item sets,
which can be exponential in |G|.
"""
productions: list[Production] = [(START, (start,))] + [(a, tuple(r)) for a, r in grammar]
nts = {a for a, _ in productions}
first, nullable = first_sets(productions, nts)
i0 = closure({(0, 0, END)}, productions, first, nullable) # @line L1
t = LRTables(productions, nts, [i0])
index = {i0: 0} # @line L2
trans: dict[tuple[int, str], int] = {}
i = 0
while i < len(t.states): # @line L3
symbols: list[str] = []
for p, d, _ in sorted(t.states[i]):
rhs = productions[p][1]
if d < len(rhs) and rhs[d] not in symbols:
symbols.append(rhs[d])
for x in symbols: # @line L4
j = goto_set(t.states[i], x, productions, first, nullable) # @line L5
if j not in index: # @line L6
index[j] = len(t.states)
t.states.append(j)
trans[(i, x)] = index[j] # @line L7
i += 1 # @line L8
def put(key, act):
if key in t.action and t.action[key] != act:
if (key, t.action[key], act) not in t.conflicts:
t.conflicts.append((key, t.action[key], act))
else:
t.action[key] = act
for s, items in enumerate(t.states): # @line L9
for p, d, la in sorted(items):
lhs, rhs = productions[p]
if d < len(rhs) and rhs[d] not in nts: # @line L10
put((s, rhs[d]), ("shift", trans[(s, rhs[d])]))
elif d == len(rhs) and p != 0: # @line L11
put((s, la), ("reduce", p))
elif d == len(rhs): # @line L12
put((s, END), ("accept",))
t.goto = {(s, x): v for (s, x), v in trans.items() if x in nts} # @line L13
return t
def parse(t: LRTables, tokens: Sequence[str]) -> list[int] | None:
"""Run the LR driver on tokens. Return the list of reductions (indices into the
input grammar, from 0) in the order made, which is a rightmost derivation in
reverse, or None when the input is rejected. Cost: O(n) steps for an LR(1) grammar.
"""
toks = list(tokens) + [END]
stack = [0] # @line L18
pos = 0
out: list[int] = []
while True: # @line L19
s = stack[-1] # @line L20
act = t.action.get((s, toks[pos]))
if act is None: # @line L24
return None
if act[0] == "shift": # @line L21
stack.append(act[1])
pos += 1
elif act[0] == "reduce": # @line L22
lhs, rhs = t.productions[act[1]]
if rhs:
del stack[-len(rhs):]
stack.append(t.goto[(stack[-1], lhs)])
out.append(act[1] - 1)
else: # @line L23
return out
test_canonical_lr_parsing.py 179 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/canonical-lr-parsing/. The authors are listed in prov/12M6QZ.jsonl.
import itertools
import random
from canonical_lr_parsing import END, build_tables, parse
def g(text):
"""'S -> C C | d ; C -> c' into (start, productions); 'ε' is the empty string."""
prods, start = [], None
for part in text.split(";"):
lhs, rhs = part.split("->")
lhs = lhs.strip()
start = start or lhs
for alt in rhs.split("|"):
prods.append((lhs, tuple(x for x in alt.split() if x != "ε")))
return start, prods
def derives(prods, w):
"""Oracle: the least fixpoint of {(X, i, j) : X =>* w[i:j]}, exact for every grammar."""
n = len(w)
t = {(w[i], i, i + 1) for i in range(n)}
changed = True
while changed:
changed = False
for a, rhs in prods:
for i in range(n + 1):
cur = {i}
for x in rhs:
cur = {j for p in cur for j in range(p, n + 1) if (x, p, j) in t}
for j in cur:
if (a, i, j) not in t:
t.add((a, i, j))
changed = True
return t
def trees(start, prods, w, cap=2):
"""Oracle: the number of parse trees of w, capped at cap (a cycle counts as cap)."""
t = derives(prods, w)
nts = {a for a, _ in prods}
memo, active = {}, set()
def cnt(x, i, j):
if x not in nts:
return 1 if j == i + 1 and w[i] == x else 0
if (x, i, j) not in t:
return 0
if (x, i, j) in memo:
return memo[(x, i, j)]
if (x, i, j) in active:
return cap
active.add((x, i, j))
total = sum(seq(rhs, 0, i, j) for a, rhs in prods if a == x)
active.discard((x, i, j))
memo[(x, i, j)] = min(total, cap)
return memo[(x, i, j)]
def seq(rhs, k, i, j):
if k == len(rhs):
return 1 if i == j else 0
return sum(cnt(rhs[k], i, m) * seq(rhs, k + 1, m, j) for m in range(i, j + 1) if cnt(rhs[k], i, m))
return cnt(start, 0, len(w))
def replay(start, prods, reductions, w):
"""Read the reductions backwards as a rightmost derivation and return its yield."""
nts = {a for a, _ in prods}
form = [start]
for k in reversed(reductions):
a, rhs = prods[k]
pos = max(i for i, x in enumerate(form) if x in nts)
assert form[pos] == a
form[pos:pos + 1] = list(rhs)
assert all(x not in nts for x in form)
return "".join(form)
def words(alphabet, max_len):
for k in range(max_len + 1):
for t in itertools.product(alphabet, repeat=k):
yield "".join(t)
def check(start, prods, alphabet, max_len):
t = build_tables(start, prods)
for w in words(alphabet, max_len):
member = (start, 0, len(w)) in derives(prods, w)
if t.conflicts:
assert trees(start, prods, w) <= 2
continue
assert trees(start, prods, w) <= 1, "a conflict-free table proves the grammar unambiguous"
out = parse(t, w)
assert (out is not None) == member, (prods, w)
if out is not None:
assert replay(start, prods, out, w) == w
return t
def test_dragon_book_example():
start, prods = g("S -> C C ; C -> c C | d")
t = check(start, prods, "cd", 7)
assert len(t.states) == 10 and not t.conflicts
# I3 = GOTO(I0, c) and I6 = GOTO(I2, c) have the same core but different lookaheads
assert t.action[(0, "c")] == ("shift", 3) and t.action[(2, "c")] == ("shift", 6)
assert t.action[(4, "c")] == ("reduce", 3) and t.action[(4, "d")] == ("reduce", 3)
assert (4, END) not in t.action and t.action[(7, END)] == ("reduce", 3)
assert t.goto[(0, "S")] == 1 and t.goto[(0, "C")] == 2 and t.goto[(6, "C")] == 9
# cdd: shift c, shift d, C -> d, C -> c C, shift d, C -> d, S -> C C
assert parse(t, "cdd") == [2, 1, 2, 0]
assert parse(t, "cd") is None and parse(t, "ddd") is None
def test_expressions_left_recursion():
start, prods = g("E -> E + T | T ; T -> T * F | F ; F -> ( E ) | i")
t = check(start, prods, "i+*()", 5)
assert len(t.states) == 22 and not t.conflicts
out = parse(t, "i+i*i")
assert [prods[k] for k in out] == [
("F", ("i",)), ("T", ("F",)), ("E", ("T",)), ("F", ("i",)), ("T", ("F",)),
("F", ("i",)), ("T", ("T", "*", "F")), ("E", ("E", "+", "T"))]
def test_lr1_not_lalr1():
start, prods = g("S -> a A d | b B d | a B e | b A e ; A -> c ; B -> c")
t = check(start, prods, "abcde", 4)
assert not t.conflicts
assert parse(t, "acd") == [4, 0] and parse(t, "ace") == [5, 2]
def test_ambiguous_grammars_conflict():
for text in ["E -> E + E | a", "S -> i S | i S e S | a", "S -> S S | ( S ) | ε", "S -> A | B ; A -> a ; B -> a"]:
start, prods = g(text)
assert build_tables(start, prods).conflicts
def test_epsilon_and_empty_input():
start, prods = g("S -> ε")
t = check(start, prods, "a", 3)
assert parse(t, "") == [0] and parse(t, "a") is None
start, prods = g("S -> ( S ) S | ε")
check(start, prods, "()", 8)
start, prods = g("S -> A B ; A -> a A | ε ; B -> b B | ε")
check(start, prods, "ab", 6)
start, prods = g("S -> A ; A -> B ; B -> b") # unit productions
t = check(start, prods, "b", 3)
assert parse(t, "b") == [2, 1, 0]
def test_long_input_no_recursion():
start, prods = g("E -> E + T | T ; T -> i")
t = build_tables(start, prods)
n = 20000
out = parse(t, "i" + "+i" * n)
assert out is not None and len(out) == 1 + 2 * n + 1
start, prods = g("E -> T + E | T ; T -> i") # right recursion: the stack grows
t = build_tables(start, prods)
assert len(parse(t, "i" + "+i" * n)) == 2 * (n + 1)
def test_random_against_brute_force():
rng = random.Random(1965)
names = ["S", "A", "B"]
conflict_free = 0
for _ in range(700):
k = rng.randint(1, 3)
prods = []
for a in names[:k]:
for _ in range(rng.randint(1, 3)):
rhs = tuple(rng.choice(names[:k] + ["a", "b"]) for _ in range(rng.randint(0, 3)))
if (a, rhs) not in prods:
prods.append((a, rhs))
t = check("S", prods, "ab", 5)
conflict_free += not t.conflicts
assert conflict_free > 100
What the next level needs
- the tests did not run in this build