Algorithm Search⌘K

LALR(1) parsing in Python

Merges LR(1) states with identical cores, giving LR(1)-sized-down tables used by yacc.

File
lalr_parsing.py, 212 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
lalr_parsing.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lalr-parsing/. The authors are listed in prov/2JWXYX.jsonl.

"""LALR(1) parse tables and the shift-reduce driver that uses them.

A grammar is a list of productions (A, rhs), where rhs is a tuple of symbols.
The nonterminals are the left sides; every other symbol is a terminal. The
grammar is augmented with production 0, S' -> start, and the end marker "$".
The LR(0) automaton is built first; the LALR(1) lookahead sets of its kernel
items are then computed by propagation until nothing changes. The grammar
should have no useless symbols; then the LALR(1) automaton is the canonical
LR(1) automaton with its states merged by core. A parse tree is (A, children),
where each child is a tree or a terminal symbol.
"""

from dataclasses import dataclass, field
from typing import Optional, Sequence, Union

Tree = tuple[str, tuple[Union["Tree", str], ...]]
Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int]  # (production index, dot position)
Action = tuple[str, int]  # ("shift", state), ("reduce", production), ("accept", 0)
END = "$"


@dataclass
class LalrTable:
    """The LALR(1) automaton, its lookahead sets and its parse table."""

    productions: list[Production]
    nonterminals: set[str]
    kernels: list[frozenset[Item]]
    goto: dict[tuple[int, str], int]
    lookahead: dict[tuple[int, Item], set[str]]
    action: dict[tuple[int, str], set[Action]] = field(default_factory=dict)

    def conflicts(self) -> list[tuple[int, str, set[Action]]]:
        """The table entries with more than one action."""
        return [(q, a, acts) for (q, a), acts in sorted(self.action.items()) if len(acts) > 1]


def _first_sets(productions: Sequence[Production], nts: set[str]):
    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 productions:
            for x in rhs:
                add = first[x] if x in nts else {x}
                if not add <= first[a]:
                    first[a] |= add
                    changed = True
                if x not in nullable:
                    break
            else:
                if a not in nullable:
                    nullable.add(a)
                    changed = True
    return nullable, first


def _first_of(seq: Sequence[str], tail: set[str], nts: set[str], nullable: set[str], first) -> set[str]:
    """FIRST(seq L) for a set L of terminals."""
    out: set[str] = set()
    for x in seq:
        if x not in nts:
            out.add(x)
            return out
        out |= first[x]
        if x not in nullable:
            return out
    return out | tail


def _closure0(kernel: frozenset[Item], prods, by_lhs, nts) -> list[Item]:
    items = list(kernel)
    seen = set(items)
    k = 0
    while k < len(items):
        p, dot = items[k]
        k += 1
        rhs = prods[p][1]
        if dot < len(rhs) and rhs[dot] in nts:
            for q in by_lhs[rhs[dot]]:
                if (q, 0) not in seen:
                    seen.add((q, 0))
                    items.append((q, 0))
    return items


def _closure1(kernel_la: dict[Item, set[str]], prods, by_lhs, nts, nullable, first) -> dict[Item, set[str]]:
    """LR(1) closure where each LR(0) item carries a set of lookaheads."""
    la = {it: set(s) for it, s in kernel_la.items()}
    work = list(la)
    while work:
        p, dot = work.pop()
        rhs = prods[p][1]
        if dot < len(rhs) and rhs[dot] in nts:
            new = _first_of(rhs[dot + 1:], la[(p, dot)], nts, nullable, first)
            for q in by_lhs[rhs[dot]]:
                cur = la.setdefault((q, 0), set())
                if not new <= cur:
                    cur |= new
                    work.append((q, 0))
    return la


def lalr_table(productions: Sequence[Production], start: str) -> LalrTable:
    """Build the LALR(1) table of the grammar augmented with S' -> start."""
    symbols = {a for a, _ in productions} | {x for _, r in productions for x in r}
    if END in symbols:
        raise ValueError("the end marker $ is a grammar symbol")
    aug = start + "'"
    while aug in symbols:
        aug += "'"
    prods: list[Production] = [(aug, (start,))] + [(a, tuple(r)) for a, r in productions]
    nts = {a for a, _ in prods}
    by_lhs: dict[str, list[int]] = {a: [] for a in nts}
    for p, (a, _) in enumerate(prods):
        by_lhs[a].append(p)
    order: list[str] = []
    for a, r in prods:
        for x in (a,) + r:
            if x not in order:
                order.append(x)
    nullable, first = _first_sets(prods, nts)

    # L1: the LR(0) automaton, states numbered in breadth-first order.
    kernels: list[frozenset[Item]] = [frozenset({(0, 0)})]                     # @line L1
    index = {kernels[0]: 0}
    goto: dict[tuple[int, str], int] = {}
    q = 0
    while q < len(kernels):
        moves: dict[str, set[Item]] = {}
        for p, dot in _closure0(kernels[q], prods, by_lhs, nts):
            rhs = prods[p][1]
            if dot < len(rhs):
                moves.setdefault(rhs[dot], set()).add((p, dot + 1))
        for x in sorted(moves, key=order.index):
            k = frozenset(moves[x])
            if k not in index:
                index[k] = len(kernels)
                kernels.append(k)
            goto[(q, x)] = index[k]
        q += 1

    la: dict[tuple[int, Item], set[str]] = {(s, it): set() for s, k in enumerate(kernels) for it in k}
    la[(0, (0, 0))] = {END}                                                     # @line L2
    changed = True
    while changed:                                                              # @line L3
        changed = False
        for s, k in enumerate(kernels):                                         # @line L4
            closed = _closure1({it: la[(s, it)] for it in k}, prods, by_lhs, nts, nullable, first)  # @line L5
            for (p, dot), look in closed.items():                               # @line L6
                rhs = prods[p][1]
                if dot < len(rhs):
                    target = la[(goto[(s, rhs[dot])], (p, dot + 1))]
                    if not look <= target:
                        target |= look
                        changed = True                                          # @line L7

    table = LalrTable(prods, nts, kernels, goto, la)
    for s, k in enumerate(kernels):                                             # @line L8
        closed = _closure1({it: la[(s, it)] for it in k}, prods, by_lhs, nts, nullable, first)
        for (p, dot), look in closed.items():
            rhs = prods[p][1]
            if dot < len(rhs) and rhs[dot] not in nts:                          # @line L9
                table.action.setdefault((s, rhs[dot]), set()).add(("shift", goto[(s, rhs[dot])]))
            elif dot == len(rhs):                                               # @line L10
                for a in look:
                    act = ("accept", 0) if p == 0 else ("reduce", p)
                    table.action.setdefault((s, a), set()).add(act)
    return table


def lalr_parse(table: LalrTable, w: Sequence[str]) -> Optional[Tree]:
    """Run the shift-reduce driver. Return the parse tree, or None if w is rejected.
    Raises ValueError if the table has a conflict."""
    if table.conflicts():                                                       # @line L11
        raise ValueError("the grammar is not LALR(1)")
    prods = table.productions
    stack = [0]                                                                 # @line L12
    values: list[Union[Tree, str]] = []
    pos = 0
    while True:                                                                 # @line L13
        a = w[pos] if pos < len(w) else END
        acts = table.action.get((stack[-1], a))
        if not acts:
            return None                                                         # @line L17
        kind, arg = next(iter(acts))
        if kind == "shift":                                                     # @line L14
            stack.append(arg)
            values.append(a)
            pos += 1
        elif kind == "reduce":                                                  # @line L15
            lhs, rhs = prods[arg]
            m = len(rhs)
            kids = tuple(values[len(values) - m:])
            del stack[len(stack) - m:]
            del values[len(values) - m:]
            stack.append(table.goto[(stack[-1], lhs)])
            values.append((lhs, kids))
        else:                                                                   # @line L16
            tree = values[-1]
            assert not isinstance(tree, str)
            return tree


def lalr_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
    """Build the table and run the driver; raises ValueError if the grammar is not LALR(1)."""
    return lalr_parse(lalr_table(productions, start), w) is not None
test_lalr_parsing.py 250 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lalr-parsing/. The authors are listed in prov/2JWXYX.jsonl.

import itertools
import random

import pytest

from lalr_parsing import END, lalr_parse, lalr_recognize, lalr_table

DRAGON = [
    ("S", ("L", "=", "R")), ("S", ("R",)),
    ("L", ("*", "R")), ("L", ("id",)),
    ("R", ("L",)),
]


def derives(productions, start, w):
    """Independent oracle: least fixpoint of 'A derives w[i:j]' over all spans."""
    nts = {a for a, _ in productions}
    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 canonical_lr1_merged(prods):
    """Independent oracle: the canonical LR(1) collection, built item by item with
    single lookahead symbols, then merged by core. prods is the augmented list
    (production 0 is S' -> S). Returns {core: {item: lookaheads}}."""
    nts = {a for a, _ in prods}

    def first_seq(seq):
        # FIRST of a sequence by brute force over a fixed point of FIRST sets
        out = set()
        for x in seq:
            if x not in nts:
                out.add(x)
                return out, False
            out |= FIRST[x]
            if x not in NULL:
                return out, False
        return out, True

    NULL, FIRST = set(), {a: set() for a in nts}
    for _ in range(len(prods) + 2):
        for a, rhs in prods:
            f, all_null = first_seq(rhs)
            FIRST[a] |= f
            if all_null:
                NULL.add(a)

    def closure(items):
        items = set(items)
        while True:
            new = set()
            for p, d, la in items:
                rhs = prods[p][1]
                if d < len(rhs) and rhs[d] in nts:
                    f, all_null = first_seq(rhs[d + 1:])
                    looks = f | ({la} if all_null else set())
                    for q, (b, _) in enumerate(prods):
                        if b == rhs[d]:
                            for t in looks:
                                new.add((q, 0, t))
            if new <= items:
                return frozenset(items)
            items |= new

    start = closure({(0, 0, END)})
    states, todo = {start}, [start]
    while todo:
        st = todo.pop()
        syms = {prods[p][1][d] for p, d, _ in st if d < len(prods[p][1])}
        for x in syms:
            nxt = closure({(p, d + 1, la) for p, d, la in st if d < len(prods[p][1]) and prods[p][1][d] == x})
            if nxt not in states:
                states.add(nxt)
                todo.append(nxt)
    merged = {}
    for st in states:
        core = frozenset((p, d) for p, d, _ in st)
        m = merged.setdefault(core, {})
        for p, d, la in st:
            m.setdefault((p, d), set()).add(la)
    return merged


def tree_ok(tree, productions, start, w):
    prods = {(a, tuple(r)) for a, r in productions}
    if tree[0] != start:
        return False
    leaves, stack = [], [tree]
    while stack:
        t = stack.pop()
        if isinstance(t, str):
            leaves.append(t)
            continue
        label, kids = t
        if (label, tuple(k if isinstance(k, str) else k[0] for k in kids)) not in prods:
            return False
        stack.extend(reversed(kids))
    return leaves == list(w)


def closure_items(table, s):
    """All LR(0) items of state s with their LALR(1) lookaheads, recomputed from the table."""
    from lalr_parsing import _closure1, _first_sets
    prods, nts = table.productions, table.nonterminals
    by_lhs = {a: [p for p, (b, _) in enumerate(prods) if b == a] for a in nts}
    nullable, first = _first_sets(prods, nts)
    return _closure1({it: table.lookahead[(s, it)] for it in table.kernels[s]}, prods, by_lhs, nts, nullable, first)


def check_against_lr1(productions, start):
    t = lalr_table(productions, start)
    merged = canonical_lr1_merged(t.productions)
    cores = {}
    for s in range(len(t.kernels)):
        items = closure_items(t, s)
        cores[frozenset(items)] = {it: la for it, la in items.items()}
    assert set(cores) == set(merged)
    for core, items in merged.items():
        assert cores[core] == items
    return t


def reduced(prods, start):
    """True when every nonterminal derives some terminal string and is reachable."""
    nts = {a for a, _ in prods}
    gen, changed = set(), True
    while changed:
        changed = False
        for a, r in prods:
            if a not in gen and all(x in gen or x not in nts for x in r):
                gen.add(a)
                changed = True
    reach, todo = {start}, [start]
    while todo:
        a = todo.pop()
        for b, r in prods:
            if b == a:
                for x in r:
                    if x in nts and x not in reach:
                        reach.add(x)
                        todo.append(x)
    return gen == nts and reach == nts


def random_grammar(rng):
    nts = ["S", "A", "B"][: rng.randint(1, 3)]
    syms = nts + ["a", "b", "c"]
    prods = set()
    for a in nts:
        for _ in range(rng.randint(1, 3)):
            if rng.random() < 0.15:
                prods.add((a, ()))
            else:
                prods.add((a, tuple(rng.choice(syms) for _ in range(rng.randint(1, 3)))))
    return sorted(prods)


def test_dragon_example():
    t = lalr_table(DRAGON, "S")
    assert len(t.kernels) == 10 and not t.conflicts()
    # state 2 holds S -> L . = R and R -> L . ; the reduction is on $ only
    s2 = t.goto[(0, "L")]
    assert t.lookahead[(s2, (5, 1))] == {END}
    assert t.action[(s2, "=")] == {("shift", t.goto[(s2, "=")])}
    assert lalr_parse(t, ["id", "=", "*", "id"]) == (
        "S", (("L", ("id",)), "=", ("R", (("L", ("*", ("R", (("L", ("id",)),)))),))))
    assert lalr_parse(t, ["id", "="]) is None
    check_against_lr1(DRAGON, "S")


def test_lr1_not_lalr1():
    g = [
        ("S", ("a", "A", "d")), ("S", ("b", "B", "d")), ("S", ("a", "B", "e")), ("S", ("b", "A", "e")),
        ("A", ("c",)), ("B", ("c",)),
    ]
    t = check_against_lr1(g, "S")
    kinds = [sorted(k for k, _ in acts) for _, _, acts in t.conflicts()]
    assert kinds == [["reduce", "reduce"], ["reduce", "reduce"]]


def test_ambiguous_has_conflict():
    g = [("E", ("E", "+", "E")), ("E", ("a",))]
    assert lalr_table(g, "E").conflicts()
    with pytest.raises(ValueError):
        lalr_recognize(g, "E", ["a"])


def test_left_and_right_recursion_and_empty():
    left = [("S", ("S", "a")), ("S", ())]
    right = [("S", ("a", "S")), ("S", ())]
    for g in (left, right):
        t = lalr_table(g, "S")
        assert not t.conflicts()
        for m in range(0, 50):
            tree = lalr_parse(t, ["a"] * m)
            assert tree is not None and tree_ok(tree, g, "S", ["a"] * m)
        assert lalr_parse(t, ["b"]) is None


def test_long_input():
    g = [("S", ("a", "S")), ("S", ("a",))]
    w = ["a"] * 5000
    assert tree_ok(lalr_parse(lalr_table(g, "S"), w), g, "S", w)


def test_random_against_oracles():
    rng = random.Random(20261106)
    words = [list(t) for m in range(5) for t in itertools.product("abc", repeat=m)]
    lalr_count = checked = 0
    for _ in range(2000):
        g = random_grammar(rng)
        if not reduced(g, "S"):
            continue
        t = check_against_lr1(g, "S")
        if t.conflicts():
            continue
        lalr_count += 1
        for w in [[]] + rng.sample(words, 8):
            tree = lalr_parse(t, w)
            assert (tree is not None) == derives(g, "S", w), (g, w)
            if tree is not None:
                assert tree_ok(tree, g, "S", w)
            checked += 1
    assert lalr_count >= 100 and checked >= 500

What the next level needs

  • the tests did not run in this build

Read the full page about LALR(1) parsing