Parsing table in Python
The table that maps a parser state and lookahead symbol to a shift, reduce, goto, accept or error action.
- File
parsing_table.py, 288 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/parsing-table/. The authors are listed in prov/JQ131J.jsonl.
"""A parsing table: a dense grid of cells indexed by (row, column) that records conflicts.
The same structure holds an LR table (rows are automaton states, columns are the
terminals, the end marker and the nonterminals) and an LL(1) table (rows are the
nonterminals, columns are the terminals and the end marker). A cell holds the
actions added to it, in the order they were added; a cell with two different
actions is a conflict.
Actions are tuples:
("shift", q), ("reduce", p), ("goto", q), ("accept",) for LR tables,
("predict", p) for LL tables, where p is a production index.
A grammar is a list of productions (A, rhs) with rhs a tuple of symbols; the left
sides are the nonterminals, every other symbol is a terminal, and the first
production's left side is the start symbol.
"""
from collections.abc import Callable, Hashable, Sequence
END = "$"
Action = tuple
Production = tuple[str, tuple[str, ...]]
class ParsingTable:
"""Rows of cells over a fixed list of column symbols, with conflict tracking."""
def __init__(self, columns: Sequence[Hashable], rows: int = 0) -> None:
self.columns = list(columns)
self.index = {c: i for i, c in enumerate(self.columns)}
if len(self.index) != len(self.columns):
raise ValueError("column symbols must be distinct")
self.cells: list[list[tuple[Action, ...]]] = []
self.conflicting: set[tuple[int, int]] = set()
for _ in range(rows):
self.add_row()
def add_row(self) -> int:
"""Append an empty row and return its number."""
self.cells.append([() for _ in self.columns])
return len(self.cells) - 1
@property
def rows(self) -> int:
return len(self.cells)
def add(self, row: int, column: Hashable, action: Action) -> None:
"""Put action into the cell; a second, different action makes the cell a conflict."""
c = self.index[column]
cell = self.cells[row][c]
if not cell: # @line L1
self.cells[row][c] = (action,)
elif action not in cell: # @line L2
self.cells[row][c] = cell + (action,)
self.conflicting.add((row, c))
def lookup(self, row: int, column: Hashable) -> tuple[Action, ...]:
"""The actions of the cell, in insertion order; () means error."""
c = self.index.get(column)
if c is None or not 0 <= row < len(self.cells):
return ()
return self.cells[row][c] # @line L3
def conflicts(self) -> list[tuple[int, Hashable, tuple[Action, ...]]]:
"""The conflicting cells as (row, column symbol, actions), sorted by position."""
return [(r, self.columns[c], self.cells[r][c]) for r, c in sorted(self.conflicting)]
def resolve(self, choose: Callable[[int, Hashable, tuple[Action, ...]], Action | None]) -> None:
"""Replace each conflicting cell by the single action choose returns (None empties it)."""
for r, c in sorted(self.conflicting): # @line L4
pick = choose(r, self.columns[c], self.cells[r][c])
self.cells[r][c] = () if pick is None else (pick,) # @line L5
self.conflicting.clear() # @line L6
def entries(self) -> int:
"""The number of (cell, action) pairs stored."""
return sum(len(cell) for row in self.cells for cell in row)
def _symbols(grammar: Sequence[Production]) -> tuple[list[str], list[str]]:
nts: list[str] = []
for a, _ in grammar:
if a not in nts:
nts.append(a)
terms: list[str] = []
for _, rhs in grammar:
for x in rhs:
if x not in nts and x not in terms:
terms.append(x)
return nts, terms
def first_follow(grammar: Sequence[Production]) -> tuple[set[str], dict[str, set[str]], dict[str, set[str]]]:
"""Nullable nonterminals, FIRST and FOLLOW sets, by iteration to a fixed point."""
nts, _ = _symbols(grammar)
ntset = set(nts)
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 grammar:
if a not in nullable and all(x in nullable for x in rhs):
nullable.add(a)
changed = True
for x in rhs:
add = first[x] if x in ntset else {x}
if not add <= first[a]:
first[a] |= add
changed = True
if x not in nullable:
break
follow: dict[str, set[str]] = {a: set() for a in nts}
follow[nts[0]].add(END)
changed = True
while changed:
changed = False
for a, rhs in grammar:
for i, x in enumerate(rhs):
if x not in ntset:
continue
add: set[str] = set()
rest_nullable = True
for y in rhs[i + 1:]:
add |= first[y] if y in ntset else {y}
if y not in nullable:
rest_nullable = False
break
if rest_nullable:
add |= follow[a]
if not add <= follow[x]:
follow[x] |= add
changed = True
return nullable, first, follow
def ll1_table(grammar: Sequence[Production]) -> ParsingTable:
"""The LL(1) table: row i is the i-th nonterminal; conflicts are recorded, not resolved."""
nts, terms = _symbols(grammar)
ntset = set(nts)
nullable, first, follow = first_follow(grammar)
table = ParsingTable(terms + [END], rows=len(nts))
row = {a: i for i, a in enumerate(nts)}
for p, (a, rhs) in enumerate(grammar):
predict: set[str] = set()
all_nullable = True
for x in rhs:
predict |= first[x] if x in ntset else {x}
if x not in nullable:
all_nullable = False
break
if all_nullable:
predict |= follow[a]
for t in sorted(predict):
table.add(row[a], t, ("predict", p))
return table
def slr_table(grammar: Sequence[Production]) -> tuple[ParsingTable, list[Production]]:
"""The SLR(1) table over the LR(0) automaton of the augmented grammar.
Returns the table and the augmented production list, whose production 0 is
S' -> S. Columns are the terminals, the end marker and the nonterminals.
"""
nts, terms = _symbols(grammar)
start = nts[0]
aug_name = start + "'"
while aug_name in nts or aug_name in terms:
aug_name += "'"
prods: list[Production] = [(aug_name, (start,))] + list(grammar)
ntset = set(nts) | {aug_name}
_, _, follow = first_follow(prods)
order: list[str] = []
for _, rhs in prods:
for x in rhs:
if x not in order:
order.append(x)
def closure(items: frozenset[tuple[int, int]]) -> frozenset[tuple[int, int]]:
out = set(items)
todo = list(items)
while todo:
p, d = todo.pop()
rhs = prods[p][1]
if d < len(rhs) and rhs[d] in ntset:
for j, (b, _) in enumerate(prods):
if b == rhs[d] and (j, 0) not in out:
out.add((j, 0))
todo.append((j, 0))
return frozenset(out)
table = ParsingTable(terms + [END] + nts)
kernels = [frozenset({(0, 0)})]
number = {kernels[0]: 0}
table.add_row()
q = 0
while q < len(kernels):
items = closure(kernels[q])
for x in order:
nxt = frozenset((p, d + 1) for p, d in items if d < len(prods[p][1]) and prods[p][1][d] == x)
if not nxt:
continue
if nxt not in number:
number[nxt] = len(kernels)
kernels.append(nxt)
table.add_row()
target = number[nxt]
table.add(q, x, ("goto", target) if x in ntset else ("shift", target))
for p, d in sorted(items):
if d == len(prods[p][1]):
if p == 0:
table.add(q, END, ("accept",))
else:
for t in sorted(follow[prods[p][0]]):
table.add(q, t, ("reduce", p))
q += 1
return table, prods
class StepLimit(Exception):
"""The driver ran more steps than allowed (possible only after conflicts were resolved)."""
def _single(actions: tuple[Action, ...]) -> Action | None:
if not actions:
return None
if len(actions) > 1:
raise ValueError("the table still has a conflict in this cell; resolve it first")
return actions[0]
def lr_parse(table: ParsingTable, prods: Sequence[Production], tokens: Sequence[str],
max_steps: int | None = None) -> bool:
"""Run the LR driver on tokens; True when the table accepts them."""
stack = [0] # @line L7
toks = list(tokens) + [END]
i = 0
steps = 0
while True:
steps += 1
if max_steps is not None and steps > max_steps:
raise StepLimit(f"more than {max_steps} steps")
act = _single(table.lookup(stack[-1], toks[i])) # @line L8
if act is None:
return False
if act[0] == "shift": # @line L9
stack.append(act[1])
i += 1
elif act[0] == "reduce": # @line L10
a, rhs = prods[act[1]]
if rhs:
del stack[-len(rhs):]
go = _single(table.lookup(stack[-1], a))
if go is None:
return False
stack.append(go[1])
else: # @line L11
return True
def ll_parse(table: ParsingTable, grammar: Sequence[Production], tokens: Sequence[str],
max_steps: int | None = None) -> bool:
"""Run the LL(1) driver on tokens; True when the table accepts them."""
nts, _ = _symbols(grammar)
row = {a: i for i, a in enumerate(nts)}
stack = [END, nts[0]]
toks = list(tokens) + [END]
i = 0
steps = 0
while True:
steps += 1
if max_steps is not None and steps > max_steps:
raise StepLimit(f"more than {max_steps} steps")
top = stack.pop()
if top in row:
act = _single(table.lookup(row[top], toks[i]))
if act is None:
return False
stack.extend(reversed(grammar[act[1]][1]))
elif top == toks[i]:
if top == END:
return True
i += 1
else:
return False
test_parsing_table.py 266 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/parsing-table/. The authors are listed in prov/JQ131J.jsonl.
import itertools
import random
import pytest
from parsing_table import (
END,
ParsingTable,
StepLimit,
ll1_table,
ll_parse,
lr_parse,
slr_table,
)
LR_EXAMPLE = [("E", ("E", "+", "n")), ("E", ("n",))]
LL_EXAMPLE = [("E", ("n", "E'")), ("E'", ("+", "n", "E'")), ("E'", ())]
AMBIGUOUS = [("E", ("E", "+", "E")), ("E", ("n",))]
def cells(table):
return {(r, c): table.lookup(r, c) for r in range(table.rows) for c in table.columns if table.lookup(r, c)}
# ---------------------------------------------------------------- oracles
def lang_upto(grammar, max_len):
"""For each nonterminal, every terminal string of length <= max_len it derives."""
nts = {a for a, _ in grammar}
lang = {a: set() for a in nts}
changed = True
while changed:
changed = False
for a, rhs in grammar:
cur = {()}
for x in rhs:
src = lang[x] if x in nts else {(x,)}
cur = {u + v for u in cur for v in src if len(u) + len(v) <= max_len}
if not cur:
break
if not cur <= lang[a]:
lang[a] |= cur
changed = True
return lang
def tree_counts(grammar, max_len):
"""Number of parse trees, capped at 2, of each (nonterminal, string) with |string| <= max_len."""
nts = {a for a, _ in grammar}
strings = lang_upto(grammar, max_len)
count = {(a, w): 0 for a in nts for w in strings[a]}
def seq_count(rhs, w):
# number of ways rhs derives w, using the current counts; capped at 2
ways = {0: 1}
for x in rhs:
nxt = {}
for start, c in ways.items():
for end in range(start, len(w) + 1):
piece = w[start:end]
if x in nts:
k = count.get((x, piece), 0)
else:
k = 1 if piece == (x,) else 0
if k:
nxt[end] = min(2, nxt.get(end, 0) + c * k)
ways = nxt
return ways.get(len(w), 0)
changed = True
while changed:
changed = False
for a in nts:
for w in strings[a]:
total = min(2, sum(seq_count(rhs, w) for b, rhs in grammar if b == a))
if total > count[(a, w)]:
count[(a, w)] = total
changed = True
return count
def random_grammar(rng):
names = ["S", "A", "B"][: rng.randint(1, 3)]
terms = ["a", "b"]
prods = []
for a in names:
for _ in range(rng.randint(1, 3)):
if rng.random() < 0.2:
rhs = ()
else:
rhs = tuple(rng.choice(names + terms) for _ in range(rng.randint(1, 3)))
if (a, rhs) not in prods:
prods.append((a, rhs))
return prods, terms
def all_strings(terms, max_len):
for n in range(max_len + 1):
yield from itertools.product(terms, repeat=n)
# ---------------------------------------------------------------- worked example
def test_worked_example_lr_table():
table, prods = slr_table(LR_EXAMPLE)
assert prods[1:] == LR_EXAMPLE
assert table.columns == ["+", "n", END, "E"]
assert cells(table) == {
(0, "n"): (("shift", 2),),
(0, "E"): (("goto", 1),),
(1, "+"): (("shift", 3),),
(1, END): (("accept",),),
(2, "+"): (("reduce", 2),),
(2, END): (("reduce", 2),),
(3, "n"): (("shift", 4),),
(4, "+"): (("reduce", 1),),
(4, END): (("reduce", 1),),
}
assert table.entries() == 9
assert table.conflicts() == []
assert lr_parse(table, prods, ["n", "+", "n"])
assert not lr_parse(table, prods, ["n", "+"])
assert not lr_parse(table, prods, [])
def test_worked_example_ll_table():
table = ll1_table(LL_EXAMPLE)
assert table.columns == ["n", "+", END]
assert cells(table) == {
(0, "n"): (("predict", 0),),
(1, "+"): (("predict", 1),),
(1, END): (("predict", 2),),
}
assert ll_parse(table, LL_EXAMPLE, ["n", "+", "n", "+", "n"])
assert not ll_parse(table, LL_EXAMPLE, ["n", "n"])
def test_conflict_and_resolution():
table, prods = slr_table(AMBIGUOUS)
assert table.conflicts() == [(4, "+", (("shift", 3), ("reduce", 1)))]
with pytest.raises(ValueError):
lr_parse(table, prods, ["n", "+", "n", "+", "n"])
table.resolve(lambda r, c, acts: next(a for a in acts if a[0] == "reduce"))
assert table.conflicts() == []
assert table.lookup(4, "+") == (("reduce", 1),)
assert lr_parse(table, prods, ["n", "+", "n", "+", "n"])
# ---------------------------------------------------------------- edge cases
def test_empty_input_and_epsilon():
g = [("S", ("a", "S")), ("S", ())]
t, prods = slr_table(g)
assert t.conflicts() == []
assert lr_parse(t, prods, [])
assert lr_parse(t, prods, ["a", "a"])
lt = ll1_table(g)
assert lt.conflicts() == []
assert ll_parse(lt, g, [])
assert not ll_parse(lt, g, ["b"])
def test_only_epsilon():
g = [("S", ())]
t, prods = slr_table(g)
assert lr_parse(t, prods, [])
assert not lr_parse(t, prods, ["x"])
assert ll_parse(ll1_table(g), g, [])
def test_basic_operations():
t = ParsingTable(["a", "b"], rows=2)
assert t.lookup(0, "a") == ()
assert t.lookup(5, "a") == ()
assert t.lookup(0, "zzz") == ()
t.add(0, "a", ("shift", 1))
t.add(0, "a", ("shift", 1))
assert t.conflicts() == []
t.add(0, "a", ("reduce", 2))
assert t.conflicts() == [(0, "a", (("shift", 1), ("reduce", 2)))]
t.resolve(lambda r, c, acts: None)
assert t.lookup(0, "a") == ()
assert t.add_row() == 2
with pytest.raises(ValueError):
ParsingTable(["a", "a"])
def test_left_recursion_is_an_ll_conflict():
g = [("E", ("E", "+", "n")), ("E", ("n",))]
t = ll1_table(g)
assert [(r, c) for r, c, _ in t.conflicts()] == [(0, "n")]
# ---------------------------------------------------------------- random
def test_random_operations_match_a_dictionary():
rng = random.Random(1)
for _ in range(600):
cols = ["a", "b", "c", END]
t = ParsingTable(cols, rows=3)
oracle = {}
for _ in range(rng.randint(0, 12)):
r = rng.randrange(3)
c = rng.choice(cols)
act = (rng.choice(["shift", "reduce"]), rng.randrange(3))
t.add(r, c, act)
cell = oracle.setdefault((r, c), [])
if act not in cell:
cell.append(act)
for r in range(3):
for c in cols:
assert t.lookup(r, c) == tuple(oracle.get((r, c), []))
expect = sorted((r, cols.index(c)) for (r, c), v in oracle.items() if len(v) > 1)
assert [(r, cols.index(c)) for r, c, _ in t.conflicts()] == expect
assert t.entries() == sum(len(v) for v in oracle.values())
t.resolve(lambda r, c, acts: acts[-1])
for (r, c), v in oracle.items():
assert t.lookup(r, c) == (v[-1],)
def test_random_grammars_against_brute_force():
rng = random.Random(7)
max_len = 4
checked = 0
for _ in range(700):
g, terms = random_grammar(rng)
lang = lang_upto(g, max_len)[g[0][0]]
counts = tree_counts(g, max_len)
ambiguous = any(v > 1 for (a, _), v in counts.items() if a == g[0][0])
lr_t, prods = slr_table(g)
ll_t = ll1_table(g)
if ambiguous:
# an ambiguous grammar is neither SLR(1) nor LL(1): both builders must report a conflict
assert lr_t.conflicts() and ll_t.conflicts()
if not lr_t.conflicts():
for w in all_strings(terms, max_len):
assert lr_parse(lr_t, prods, w, max_steps=1000) == (w in lang), (g, w)
checked += 1
else:
lr_t.resolve(lambda r, c, acts: rng.choice(acts))
for w in all_strings(terms, max_len):
try:
if lr_parse(lr_t, prods, w, max_steps=200):
assert w in lang, (g, w)
except StepLimit:
pass
if not ll_t.conflicts():
for w in all_strings(terms, max_len):
assert ll_parse(ll_t, g, w, max_steps=1000) == (w in lang), (g, w)
checked += 1
else:
ll_t.resolve(lambda r, c, acts: rng.choice(acts))
for w in all_strings(terms, max_len):
try:
if ll_parse(ll_t, g, w, max_steps=200):
assert w in lang, (g, w)
except StepLimit:
pass
assert checked >= 500
What the next level needs
- the tests did not run in this build