Algorithm Search⌘K

LR parse table in Python

Action and goto tables that drive a shift-reduce LR parser.

File
lr_parse_table.py, 215 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
lr_parse_table.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lr-parse-table/. The authors are listed in prov/B3QKMP.jsonl.

"""LR parse tables: ACTION and GOTO, filled by the LR(0), SLR(1) or LR(1) rules.

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. A parse tree is
a pair (A, children) for a nonterminal node, and a plain string for a token leaf.
The grammar is augmented with S' -> S, and the end marker $ ends the input.
"""

END = "$"
AUG = ("S'",)  # the new start symbol; a tuple, so it cannot clash with a name

Production = tuple[str, tuple[str, ...]]
Tree = tuple[str, list] | str
Action = tuple  # ("shift", state) | ("reduce", production) | ("accept",)


class ParseError(Exception):
    """The input is not in the language; position is the index of the bad token."""

    def __init__(self, position: int, message: str) -> None:
        super().__init__(f"at token {position}: {message}")
        self.position = position


class LRParseTable:
    """ACTION[q][a] and GOTO[q][A] for the states q of an LR automaton.

    action[q] maps a terminal or $ to one action; goto[q] maps a nonterminal to a
    state; conflicts lists (q, a, actions) for every cell that got two actions.
    """

    def __init__(self, prods: list, n_states: int) -> None:
        self.prods = prods
        self.action: list[dict[str, Action]] = [{} for _ in range(n_states)]
        self.goto: list[dict[str, int]] = [{} for _ in range(n_states)]
        self.conflicts: list[tuple[int, str, set]] = []

    def set_action(self, q: int, a: str, act: Action) -> None:
        old = self.action[q].get(a)
        if old is None or old == act:  # @line L7
            self.action[q][a] = act
        else:  # @line L8
            self.conflicts.append((q, a, {old, act}))

    def lookup(self, q: int, a: str) -> Action | None:
        return self.action[q].get(a)  # @line L9


def _productive(productions: list[Production]) -> list[Production]:
    nts = {a for a, _ in productions}
    good: set[str] = set()
    changed = True
    while changed:
        changed = False
        for a, rhs in productions:
            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 productions
            if a in good and all(x in good or x not in nts for x in r)]


def _first_follow(prods, nts):
    null: set = set()
    first: dict = {a: set() for a in nts}
    follow: dict = {a: set() for a in nts}
    follow[AUG].add(END)

    def first_of(alpha):
        out: set = set()
        for x in alpha:
            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

    changed = True
    while changed:
        changed = False
        for a, rhs in prods:
            f, nul = first_of(rhs)
            if nul and a not in null:
                null.add(a)
                changed = True
            if not f <= first[a]:
                first[a] |= f
                changed = True
            for i, x in enumerate(rhs):
                if x in nts:
                    g, rest = first_of(rhs[i + 1:])
                    if rest:
                        g = g | follow[a]
                    if not g <= follow[x]:
                        follow[x] |= g
                        changed = True
    return first_of, follow


def build_table(productions: list[Production], start: str, kind: str = "slr") -> LRParseTable:
    """Build the LR automaton and fill ACTION and GOTO.

    kind is "lr0" (reduce on every terminal), "slr" (reduce on FOLLOW of the left
    side) or "lr1" (canonical LR(1) items, reduce on the item's lookahead).
    Productions that use a nonterminal deriving no terminal string are removed first.
    """
    if kind not in ("lr0", "slr", "lr1"):
        raise ValueError(f"unknown kind {kind!r}")
    prods = [(AUG, (start,))] + _productive(productions)
    nts = {a for a, _ in productions} | {start, AUG}
    terms = sorted({x for _, r in prods for x in r if x not in nts}) + [END]
    first_of, follow = _first_follow(prods, nts)
    by_lhs: dict = {}
    for p, (a, _) in enumerate(prods):
        by_lhs.setdefault(a, []).append(p)

    def closure(kernel):
        items = set(kernel)
        todo = list(kernel)
        while todo:
            it = todo.pop()
            p, d = it[0], it[1]
            rhs = prods[p][1]
            if d < len(rhs) and rhs[d] in nts:
                if kind == "lr1":
                    f, nul = first_of(rhs[d + 1:])
                    las = f | {it[2]} if nul else f
                else:
                    las = (None,)
                for q in by_lhs.get(rhs[d], ()):
                    for la in las:
                        new = (q, 0, la) if kind == "lr1" else (q, 0)
                        if new not in items:
                            items.add(new)
                            todo.append(new)
        return frozenset(items)

    start_item = (0, 0, END) if kind == "lr1" else (0, 0)
    states = [closure([start_item])]
    index = {states[0]: 0}
    trans: dict = {}
    q = 0
    while q < len(states):
        moves: dict = {}
        for it in states[q]:
            rhs = prods[it[0]][1]
            if it[1] < len(rhs):
                moves.setdefault(rhs[it[1]], []).append((it[0], it[1] + 1) + tuple(it[2:]))
        for x, kernel in moves.items():
            j = closure(kernel)
            if j not in index:
                index[j] = len(states)
                states.append(j)
            trans[q, x] = index[j]
        q += 1

    table = LRParseTable(prods, len(states))
    for q, items in enumerate(states):
        for it in sorted(items, key=repr):  # @line L1
            p, d = it[0], it[1]
            a, rhs = prods[p]
            if d < len(rhs) and rhs[d] not in nts:  # @line L2
                table.set_action(q, rhs[d], ("shift", trans[q, rhs[d]]))
            elif d == len(rhs) and p == 0:  # @line L3
                table.set_action(q, END, ("accept",))
            elif d == len(rhs):  # @line L4
                if kind == "lr0":
                    las = terms
                elif kind == "slr":
                    las = sorted(follow[a])
                else:
                    las = [it[2]]
                for b in las:  # @line L5
                    table.set_action(q, b, ("reduce", p))
    for (q, x), j in trans.items():
        if x in nts:
            table.goto[q][x] = j  # @line L6
    return table


def lr_parse(table: LRParseTable, tokens: list[str]) -> Tree:
    """Run the shift-reduce driver on tokens. Requires a table without conflicts.

    Raises ParseError at the first token whose cell is empty.
    """
    if table.conflicts:
        raise ValueError("the table has conflicts")
    prods = table.prods
    w = list(tokens) + [END]
    states = [0]
    trees: list = []
    i = 0
    while True:
        act = table.lookup(states[-1], w[i])  # @line L10
        if act is None or (w[i] == END and i != len(w) - 1):  # @line L11
            raise ParseError(i, f"unexpected {w[i]!r}")
        if act[0] == "shift":  # @line L12
            states.append(act[1])
            trees.append(w[i])
            i += 1
        elif act[0] == "reduce":  # @line L13
            a, rhs = prods[act[1]]
            k = len(rhs)
            node = (a, trees[len(trees) - k:])
            del trees[len(trees) - k:]
            del states[len(states) - k:]
            states.append(table.goto[states[-1]][a])
            trees.append(node)
        else:  # @line L14
            return trees[0]
test_lr_parse_table.py 193 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lr-parse-table/. The authors are listed in prov/B3QKMP.jsonl.

import itertools
import random

import pytest

from lr_parse_table import END, ParseError, build_table, lr_parse

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 prefix_lang(prods, max_len):
    """Oracle: for each nonterminal, the prefixes of length <= max_len of the strings it derives.

    Every nonterminal must be productive, so that each symbol after a prefix can be completed.
    """
    nts = {a for a, _ in prods}
    full = lang_upto(prods, max_len)
    pre = {a: set() for a in nts}

    def full_of(x):
        return full[x] if x in nts else {(x,)}

    def pre_of(x):
        return pre[x] if x in nts else {(), (x,)}

    changed = True
    while changed:
        changed = False
        for a, rhs in prods:
            new = {()}
            cur = {()}
            for x in rhs:
                new |= {u + v for u in cur for v in pre_of(x) if len(u) + len(v) <= max_len}
                cur = {u + v for u in cur for v in full_of(x) if len(u) + len(v) <= max_len}
            if not new <= pre[a]:
                pre[a] |= new
                changed = True
    return pre


def productive_only(prods):
    """Remove the productions that use a nonterminal deriving no terminal string."""
    nts = {a for a, _ in prods}
    good = set()
    changed = True
    while changed:
        changed = False
        for a, rhs in prods:
            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 prods if a in good and all(x in good or x not in nts for x in r)]


def check_tree(prods, start, tree, tokens):
    """Iteratively check that tree is a parse tree of tokens with root start."""
    nts = {a for a, _ in prods}
    rules = set(prods)
    assert isinstance(tree, tuple) and tree[0] == start
    leaves = []
    todo = [tree]
    while todo:
        t = todo.pop()
        if isinstance(t, str):
            assert t not in nts
            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 rules
        todo.extend(reversed(kids))
    assert leaves == list(tokens)


def random_grammar(rng, n_nt, terms, eps_prob=0.2):
    names = ["S", "A", "B", "C"][:n_nt]
    prods = []
    for a in names:
        for _ in range(rng.randint(1, 3)):
            k = 0 if rng.random() < eps_prob else rng.randint(1, 3)
            prods.append((a, tuple(rng.choice(names + terms) for _ in range(k))))
    return prods


def test_expression_grammar_tables():
    with pytest.raises(ValueError):
        build_table(EXPR, "E", "nope")
    t0 = build_table(EXPR, "E", "lr0")
    assert len(t0.action) == 12 and len(t0.conflicts) > 0
    slr = build_table(EXPR, "E", "slr")
    assert len(slr.action) == 12 and slr.conflicts == []
    lr1 = build_table(EXPR, "E", "lr1")
    assert len(lr1.action) == 22 and lr1.conflicts == []
    n_action = sum(len(r) for r in slr.action)
    n_goto = sum(len(r) for r in slr.goto)
    assert (n_action, n_goto) == (36, 9)
    toks = ["id", "+", "id", "*", "id"]
    for t in (slr, lr1):
        tree = lr_parse(t, toks)
        check_tree(EXPR, "E", tree, toks)
        assert tree[1][1] == "+"
        for bad, pos in ((["id", "id"], 1), (["+"], 0), (["id", "+"], 2), ([], 0),
                         (["(", "id", "$", ")"], 2)):
            with pytest.raises(ParseError) as e:
                lr_parse(t, bad)
            assert e.value.position == pos
    with pytest.raises(ValueError):
        lr_parse(t0, toks)


def test_lookahead_classes():
    # LR(1) but not SLR(1): the textbook L = R grammar
    g = [("S", ("L", "=", "R")), ("S", ("R",)), ("L", ("*", "R")), ("L", ("id",)), ("R", ("L",))]
    assert build_table(g, "S", "slr").conflicts
    assert build_table(g, "S", "lr1").conflicts == []
    # empty productions and the empty input
    g2 = [("S", ("a", "S", "b")), ("S", ())]
    assert build_table(g2, "S", "lr0").conflicts
    t = build_table(g2, "S", "slr")
    assert lr_parse(t, []) == ("S", [])
    check_tree(g2, "S", lr_parse(t, ["a", "a", "b", "b"]), ["a", "a", "b", "b"])


def test_deep_input_has_no_recursion_limit():
    t = build_table(EXPR, "E", "slr")
    n = 100000
    toks = ["("] * n + ["id"] + [")"] * n
    check_tree(EXPR, "E", lr_parse(t, toks), toks)


def test_random_against_brute_force():
    rng = random.Random(7741)
    terms = ["a", "b"]
    counts = {"lr0": 0, "slr": 0, "lr1": 0}
    for _ in range(2500):
        prods = productive_only(random_grammar(rng, rng.randint(1, 4), terms))
        if not any(a == "S" for a, _ in prods):
            continue
        tables = {k: build_table(prods, "S", k) for k in counts}
        ok = {k: not t.conflicts for k, t in tables.items()}
        # each method accepts at least the grammars of the weaker one
        assert ok["slr"] or not ok["lr0"]
        assert ok["lr1"] or not ok["slr"]
        bound = 6
        lang = lang_upto(prods, bound)
        prefixes = prefix_lang(prods, bound + 1)["S"]
        for k, t in tables.items():
            if not ok[k]:
                continue
            counts[k] += 1
            for m in range(bound + 1):
                for w in itertools.product(terms, repeat=m):
                    if w in lang["S"]:
                        check_tree(prods, "S", lr_parse(t, list(w)), w)
                    else:
                        with pytest.raises(ParseError) as e:
                            lr_parse(t, list(w))
                        i = e.value.position
                        assert w[:i] in prefixes, (k, prods, w, i)
                        assert w[:i + 1] not in prefixes or i == len(w), (k, prods, w, i)
    assert min(counts.values()) >= 500, counts

What the next level needs

  • the tests did not run in this build

Read the full page about LR parse table