Left recursion elimination algorithm in Python
Systematic procedure that removes direct and indirect left recursion from a grammar.
- File
left_recursion_elimination.py, 129 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/left-recursion-elimination/. The authors are listed in prov/KFFADV.jsonl.
"""Paull's algorithm: remove all left recursion from a context-free grammar.
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. The result
generates the same language and has no derivation A =>+ A beta.
"""
Production = tuple[str, tuple[str, ...]]
def _fresh(name: str, used: set[str]) -> str:
new = name + "'"
while new in used:
new += "'"
used.add(new)
return new
def prepare(productions: list[Production], start: str) -> tuple[list[Production], bool]:
"""Make the grammar fit for the main loop: no useless rules, no empty rules, no unit cycles.
Returns the new productions and whether the empty string is in the language.
The language changes only by the loss of the empty string.
"""
nts = {a for a, _ in productions} | {start}
def productive(ps: list[Production]) -> list[Production]:
good: set[str] = set()
changed = True
while changed:
changed = False
for a, rhs in ps:
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 ps if a in good and all(x in good or x not in nts for x in r)]
# 1. keep only productions whose symbols all derive some terminal string
prods = productive(productions)
# 2. remove empty productions: add every version with some nullable symbols left out
null: set[str] = set()
changed = True
while changed:
changed = False
for a, rhs in prods:
if a not in null and all(x in null for x in rhs):
null.add(a)
changed = True
out: list[Production] = []
seen: set[Production] = set()
for a, rhs in prods:
versions: list[tuple[str, ...]] = [()]
for x in rhs:
versions = [v + (x,) for v in versions] + (versions if x in null else [])
for v in versions:
if v and (a, v) not in seen:
seen.add((a, v))
out.append((a, v))
# a symbol that derived only the empty string now derives nothing
out = productive(out)
# 3. merge each cycle of unit productions A -> B -> ... -> A into one nonterminal
unit = {a: {r[0] for b, r in out if b == a and len(r) == 1 and r[0] in nts} for a in nts}
reach = {}
for a in nts:
seen_a, todo = {a}, [a]
while todo:
for b in unit[todo.pop()]:
if b not in seen_a:
seen_a.add(b)
todo.append(b)
reach[a] = seen_a
order = [start] + [a for a, _ in productions if a != start]
rep = {}
for a in order:
if a not in rep:
for b in reach[a]:
if a in reach[b] and b not in rep:
rep[b] = a
merged: list[Production] = []
seen = set()
for a, rhs in out:
p = (rep[a], tuple(rep.get(x, x) for x in rhs))
if p != (p[0], (p[0],)) and p not in seen:
seen.add(p)
merged.append(p)
return merged, start in null
def eliminate_left_recursion(productions: list[Production], start: str) -> tuple[list[Production], str]:
"""Return (productions, start) of an equivalent grammar without left recursion.
The nonterminals are processed in the order of their first appearance as a left
side, with the start symbol first. Cost: O(m * h) time after prepare, where m is
the number of nonterminals and h the size of the result, which can be exponential
in the size of the input.
"""
used = {a for a, _ in productions} | {s for _, r in productions for s in r} | {start}
prods, has_empty = prepare(productions, start)
order: list[str] = []
for a, _ in [(start, ())] + prods:
if a not in order and any(b == a for b, _ in prods):
order.append(a)
rules = {a: [r for b, r in prods if b == a] for a in order} # @line L1
result: list[Production] = []
for i, ai in enumerate(order): # @line L2
for aj in order[:i]: # @line L3
new: list[tuple[str, ...]] = []
for rhs in rules[ai]: # @line L4
if rhs[0] == aj:
new.extend(delta + rhs[1:] for delta in rules[aj]) # @line L5
else:
new.append(rhs)
rules[ai] = list(dict.fromkeys(new))
alphas = [r[1:] for r in rules[ai] if r[0] == ai] # @line L6
betas = [r for r in rules[ai] if r[0] != ai]
if alphas: # @line L7
prime = _fresh(ai, used)
rules[ai] = [b + (prime,) for b in betas] # @line L8
result.extend((prime, a + (prime,)) for a in alphas) # @line L9
result.append((prime, ()))
final = [(a, r) for a in order for r in rules[a]] + result # @line L10
if has_empty:
s0 = _fresh(start, used)
top = [(s0, (start,))] if start in rules else []
return top + [(s0, ())] + final, s0
return final, start
test_left_recursion_elimination.py 141 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/left-recursion-elimination/. The authors are listed in prov/KFFADV.jsonl.
import random
from left_recursion_elimination import eliminate_left_recursion, prepare
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 is_left_recursive(prods):
"""True when some nonterminal A has A =>+ A beta, found on the left-corner graph."""
nts = {a for a, _ in prods}
null = set()
changed = True
while changed:
changed = False
for a, r in prods:
if a not in null and all(x in null for x in r):
null.add(a)
changed = True
edge = {a: set() for a in nts}
for a, r in prods:
for x in r:
if x in nts:
edge[a].add(x)
if x not in null:
break
for a in nts:
seen, todo = set(), list(edge[a])
while todo:
b = todo.pop()
if b == a:
return True
if b not in seen:
seen.add(b)
todo.extend(edge[b])
return False
def random_grammar(rng, n_nt, terms):
names = ["S", "A", "B", "C"][:n_nt]
prods = []
for a in names:
for _ in range(rng.randint(1, 3)):
r = rng.random()
if r < 0.15:
rhs = ()
elif r < 0.3:
rhs = (rng.choice(names),) # unit production, maybe a cycle
elif r < 0.5:
rhs = (a,) + tuple(rng.choice(names + terms) for _ in range(rng.randint(1, 2)))
else:
rhs = tuple(rng.choice(names + terms) for _ in range(rng.randint(1, 3)))
prods.append((a, rhs))
return prods
def test_expression_grammar():
out, start = eliminate_left_recursion(EXPR, "E")
assert start == "E"
assert sorted(out) == sorted([
("E", ("T", "E'")), ("E'", ("+", "T", "E'")), ("E'", ()),
("T", ("F", "T'")), ("T'", ("*", "F", "T'")), ("T'", ()),
("F", ("(", "E", ")")), ("F", ("id",)),
])
def test_indirect_and_empty():
g = [("S", ("A", "a")), ("S", ("b",)), ("A", ("A", "c")), ("A", ("S", "d")), ("A", ())]
out, start = eliminate_left_recursion(g, "S")
assert not is_left_recursive(out)
assert lang_upto(out, 8)[start] == lang_upto(g, 8)["S"]
# empty language, a pure cycle, the empty string only
assert eliminate_left_recursion([("S", ("S", "a"))], "S")[0] == []
out, start = eliminate_left_recursion([("S", ("S",)), ("S", ("a",))], "S")
assert out == [("S", ("a",))]
out, start = eliminate_left_recursion([("S", ())], "S")
assert lang_upto(out, 3)[start] == {()}
def test_prepare():
prods, has_empty = prepare([("S", ("A", "B")), ("A", ("B",)), ("B", ("A",)), ("B", ("b",)), ("A", ())], "S")
assert has_empty
assert all(r for _, r in prods)
assert not any(len(r) == 1 and r[0] == a for a, r in prods)
def test_exponential_growth_depends_on_order():
m = 8
g = [("A0", ("a",))]
for i in range(1, m):
g += [(f"A{i}", (f"A{i - 1}", "x")), (f"A{i}", (f"A{i - 1}", "y"))]
fwd, _ = eliminate_left_recursion(g, "A0")
rev, _ = eliminate_left_recursion(list(reversed(g)), f"A{m - 1}")
count = lambda ps, a: sum(1 for b, _ in ps if b == a)
assert count(fwd, f"A{m - 1}") == 2 ** (m - 1)
assert count(rev, f"A{m - 1}") == 2
assert lang_upto(fwd, 9)["A7"] == lang_upto(rev, 9)["A7"] == lang_upto(g, 9)["A7"]
def test_random_against_brute_force():
rng = random.Random(2024)
terms = ["a", "b"]
n_lr = 0
for _ in range(2000):
g = random_grammar(rng, rng.randint(1, 4), terms)
if is_left_recursive(g):
n_lr += 1
out, start = eliminate_left_recursion(g, "S")
assert not is_left_recursive(out), (g, out)
assert lang_upto(out, 7).get(start, set()) == lang_upto(g, 7)["S"], (g, out)
assert all(not r or r[0] != a for a, r in out)
assert n_lr > 700
What the next level needs
- the tests did not run in this build
Read the full page about Left recursion elimination algorithm