Generalized LR parsing in Python
Extends LR parsing to any context-free grammar by forking the parser on conflicts.
- File
glr_parsing.py, 175 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/glr-parsing/. The authors are listed in prov/Z0RM8G.jsonl.
"""Generalized LR (GLR) recognizer and parser for any context-free grammar.
A grammar is a list of productions (A, rhs), where rhs is a tuple of symbols.
The nonterminals are the left sides and the start symbol; every other symbol
is a terminal. Empty right sides, unit productions, left recursion, hidden
left recursion, cycles and ambiguity are all allowed.
The parser runs the LR(0) automaton of the grammar on all branches at once.
The branches share one graph-structured stack: a node is a pair (state,
level), where the level is the number of input symbols read when the node was
pushed, and an edge v -> x means that the cell v lies directly on the cell x.
Every edge carries one parse tree for the grammar symbol that the edge stands
for, so an accepting edge gives one parse tree of the input. A parse tree is
(A, children), where each child is a tree or a terminal symbol.
"""
from typing import Optional, Sequence, Union
Tree = tuple[str, tuple[Union["Tree", str], ...]]
Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int]
Edge = tuple[int, int]
class Lr0Automaton:
"""The LR(0) automaton of the grammar augmented with S' -> S.
Production 0 of `prods` is the augmenting production; production p >= 1
is productions[p - 1]. `goto[q]` maps a symbol to the next state, and
`reductions[q]` lists the productions p >= 1 with a complete item in q.
"""
def __init__(self, prods: list[Production], states: list[frozenset[Item]],
goto: list[dict[str, int]], reductions: list[list[int]]) -> None:
self.prods = prods
self.states = states
self.goto = goto
self.reductions = reductions
def lr0_automaton(productions: Sequence[Production], start: str) -> Lr0Automaton:
"""Build the canonical LR(0) automaton by closure and goto."""
prods: list[Production] = [("S'", (start,))] + [(a, tuple(r)) for a, r in productions]
nts = {a for a, _ in productions} | {start}
by_lhs: dict[str, list[int]] = {}
for p, (a, _) in enumerate(prods):
if p > 0:
by_lhs.setdefault(a, []).append(p)
def closure(kernel: set[Item]) -> frozenset[Item]:
items = set(kernel)
todo = list(kernel)
while todo:
p, d = todo.pop()
rhs = prods[p][1]
if d < len(rhs) and rhs[d] in nts:
for q in by_lhs.get(rhs[d], []):
if (q, 0) not in items:
items.add((q, 0))
todo.append((q, 0))
return frozenset(items)
states = [closure({(0, 0)})]
index = {states[0]: 0}
goto: list[dict[str, int]] = []
k = 0
while k < len(states):
moves: dict[str, set[Item]] = {}
for p, d in states[k]:
rhs = prods[p][1]
if d < len(rhs):
moves.setdefault(rhs[d], set()).add((p, d + 1))
row: dict[str, int] = {}
for x, kernel in moves.items():
target = closure(kernel)
if target not in index:
index[target] = len(states)
states.append(target)
row[x] = index[target]
goto.append(row)
k += 1
reductions = [sorted(p for p, d in s if p > 0 and d == len(prods[p][1])) for s in states]
return Lr0Automaton(prods, states, goto, reductions)
def glr_parse(productions: Sequence[Production], start: str, w: Sequence[str]) -> Optional[Tree]:
"""Return one parse tree of w from start, or None if w is not in the language."""
aut = lr0_automaton(productions, start) # @line L1
prods, goto, red = aut.prods, aut.goto, aut.reductions
nts = {a for a, _ in productions} | {start}
n = len(w)
node_of: dict[tuple[int, int], int] = {}
state: list[int] = []
level: list[int] = []
out: list[dict[int, Union[Tree, str]]] = [] # out[v][x] = tree on the edge v -> x
def new_node(s: int, j: int) -> int:
node_of[(s, j)] = len(state)
state.append(s)
level.append(j)
out.append({})
return len(state) - 1
root = new_node(0, 0) # @line L2
frontier = [root]
for j in range(n + 1): # @line L3
queue: list[tuple[int, int, Optional[Edge]]] = [
(u, p, None) for u in frontier for p in red[state[u]]] # @line L4
while queue: # @line L5
u, p, e = queue.pop() # @line L6
a, rhs = prods[p]
for x, kids in _paths(out, level, u, len(rhs), e, j): # @line L7
tree: Tree = (a, tuple(kids))
key = (goto[state[x]][a], j)
v = node_of.get(key)
if v is None: # @line L8
v = new_node(*key)
frontier.append(v)
queue.extend((v, q, None) for q in red[key[0]])
elif x not in out[v]: # @line L9
queue.extend((u2, q, (v, x)) for u2 in frontier
for q in red[state[u2]] if prods[q][1])
if x not in out[v]: # @line L10
out[v][x] = tree
if j < n: # @line L11
nxt: list[int] = []
a = w[j]
for u in frontier:
s = goto[state[u]].get(a) if a not in nts else None
if s is None:
continue
v = node_of.get((s, j + 1)) # @line L12
if v is None:
v = new_node(s, j + 1)
nxt.append(v)
out[v][u] = a
frontier = nxt
acc = goto[0].get(start) # @line L13
top = node_of.get((acc, n)) if acc is not None else None
if top is None or root not in out[top]:
return None
tree = out[top][root]
return tree if isinstance(tree, tuple) else None
def glr_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
"""Return True if w is in the language of the grammar."""
return glr_parse(productions, start, w) is not None
def _paths(out: list[dict[int, Union[Tree, str]]], level: list[int], u: int, m: int,
through: Optional[Edge], j: int) -> list[tuple[int, list[Union[Tree, str]]]]:
"""All paths of m edges from u, as (end node, edge trees from bottom to top).
If `through` is an edge (v, x) whose source is at level j, only paths that
use it are returned. Such a path stays at level j until it uses the edge,
because edges never go up a level, so a path that leaves level j first is
cut off. The search keeps its own stack and does not recurse.
"""
result: list[tuple[int, list[Union[Tree, str]]]] = []
stack: list[tuple[int, int, bool, list[Union[Tree, str]]]] = [(u, 0, through is None, [])]
while stack:
node, depth, used, trees = stack.pop()
if depth == m:
if used:
result.append((node, trees[::-1]))
continue
if not used and level[node] < j:
continue
for x, t in out[node].items():
stack.append((x, depth + 1, used or (node, x) == through, trees + [t]))
return result
test_glr_parsing.py 135 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/glr-parsing/. The authors are listed in prov/Z0RM8G.jsonl.
import itertools
import random
from glr_parsing import glr_parse, glr_recognize, lr0_automaton
EXAMPLE = [("S", ("S", "S")), ("S", ("a",))]
def derives(productions, start, w):
"""Independent oracle: least fixpoint of 'A derives w[i:j]' over all spans."""
nts = {a for a, _ in productions} | {start}
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 check_tree(productions, start, w, tree):
"""Check without recursion that tree is a parse tree of w from start."""
prods = {(a, tuple(r)) for a, r in productions}
assert tree[0] == start
leaves = []
stack = [tree]
while stack:
t = stack.pop()
if isinstance(t, str):
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 prods, (a, labels)
stack.extend(reversed(kids))
assert leaves == list(w)
def random_grammar(rng):
nts = ["S", "A", "B"][: rng.randint(1, 3)]
syms = nts + ["a", "b"]
prods = []
for a in nts:
for _ in range(rng.randint(1, 3)):
k = rng.choice([0, 1, 1, 2, 2, 3])
prods.append((a, tuple(rng.choice(syms) for _ in range(k))))
if rng.random() < 0.3: # force left recursion
prods.append(("S", ("S", rng.choice(syms))))
if rng.random() < 0.2: # hidden left recursion and cycles
prods.append(("S", (rng.choice(nts), "S")))
return prods
def test_example():
for k in range(1, 7):
w = ["a"] * k
tree = glr_parse(EXAMPLE, "S", w)
check_tree(EXAMPLE, "S", w, tree)
assert glr_parse(EXAMPLE, "S", []) is None
assert glr_parse(EXAMPLE, "S", ["b"]) is None
def test_example_automaton():
aut = lr0_automaton(EXAMPLE, "S")
assert len(aut.states) == 4
conflict = [q for q, s in enumerate(aut.states) if aut.reductions[q] and "a" in aut.goto[q]]
assert len(conflict) == 1
def test_empty_input_and_epsilon():
g = [("S", ()), ("S", ("A", "S")), ("A", ())]
assert glr_recognize(g, "S", [])
assert not glr_recognize(g, "S", ["a"])
g2 = [("S", ("A", "S", "b")), ("S", ("a",)), ("A", ())]
for k in range(5):
w = ["a"] + ["b"] * k
assert glr_recognize(g2, "S", w)
check_tree(g2, "S", w, glr_parse(g2, "S", w))
assert not glr_recognize(g2, "S", ["b"])
def test_cycles_and_units():
g = [("S", ("S",)), ("S", ("A",)), ("A", ("S",)), ("A", ("a",)), ("S", ("S", "S"))]
for k in range(5):
assert glr_recognize(g, "S", ["a"] * k) == (k >= 1)
def test_start_without_productions():
assert not glr_recognize([("A", ("a",))], "S", ["a"])
assert not glr_recognize([], "S", [])
def test_random_against_oracle():
rng = random.Random(20261106)
inputs = 0
for _ in range(250):
g = random_grammar(rng)
words = [list(t) for m in range(4) for t in itertools.product("ab", repeat=m)]
words += [[rng.choice("ab") for _ in range(rng.randint(4, 7))] for _ in range(3)]
for w in words:
expect = derives(g, "S", w)
tree = glr_parse(g, "S", w)
assert (tree is not None) == expect, (g, w)
if tree is not None:
check_tree(g, "S", w, tree)
inputs += 1
assert inputs >= 500
def test_long_input_no_recursion_limit():
g = [("S", ("S", "a")), ("S", ("a", "S")), ("S", ())]
w = ["a"] * 300
tree = glr_parse(g, "S", w)
check_tree(g, "S", w, tree)
What the next level needs
- the tests did not run in this build