Algorithm Search⌘K

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
left_recursion_elimination.pyRawGitHub
# 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