Algorithm Search⌘K

Canonical LR(1) parsing in Python

The most powerful LR parser with one token of lookahead, using full LR(1) item sets.

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

from dataclasses import dataclass, field
from collections.abc import Sequence

END = "$"  # the end marker; it must not be a terminal of the grammar
START = "S'"  # the new start symbol of the augmented grammar

Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int, str]  # (production index, dot position, lookahead)


@dataclass
class LRTables:
    """The canonical LR(1) automaton and its parse tables.

    productions[0] is S' -> S; productions[k] for k >= 1 is the (k-1)-th input production.
    action[(state, terminal)] is ("shift", t), ("reduce", k) or ("accept",).
    goto[(state, nonterminal)] is a state. conflicts lists each entry that got two actions.
    """

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


def first_sets(productions: Sequence[Production], nts: set[str]) -> tuple[dict[str, set[str]], set[str]]:
    """FIRST of each nonterminal and the set of nullable nonterminals, by a fixpoint.

    Cost: O(|G| * |V| * |Sigma|) set work in the worst case.
    """
    first: dict[str, set[str]] = {a: set() for a in nts}
    nullable: set[str] = set()
    changed = True
    while changed:
        changed = False
        for a, rhs in productions:
            all_nullable = True
            for x in rhs:
                f = first[x] if x in nts else {x}
                if not f <= first[a]:
                    first[a] |= f
                    changed = True
                if x not in nullable:
                    all_nullable = False
                    break
            if all_nullable and a not in nullable:
                nullable.add(a)
                changed = True
    return first, nullable


def first_of(seq: Sequence[str], la: str, first: dict[str, set[str]], nullable: set[str]) -> set[str]:
    """FIRST(seq la): the terminals that can begin a string derived from seq followed by la."""
    out: set[str] = set()
    for x in seq:
        if x in first:
            out |= first[x]
            if x not in nullable:
                return out
        else:
            out.add(x)
            return out
    out.add(la)
    return out


def closure(items, productions, first, nullable) -> frozenset[Item]:
    """CLOSURE of a set of LR(1) items, with an explicit work list."""
    result = set(items)  # @line L14
    work = list(items)
    while work:  # @line L16
        p, d, la = work.pop()
        rhs = productions[p][1]
        if d < len(rhs) and rhs[d] in first:  # @line L15
            for b in first_of(rhs[d + 1:], la, first, nullable):
                for q, (lhs, _) in enumerate(productions):
                    if lhs == rhs[d] and (q, 0, b) not in result:
                        result.add((q, 0, b))
                        work.append((q, 0, b))
    return frozenset(result)


def goto_set(items, x, productions, first, nullable) -> frozenset[Item]:
    """GOTO(I, X): move the dot over X in each item that allows it, then close."""
    kernel = {(p, d + 1, la) for p, d, la in items
              if d < len(productions[p][1]) and productions[p][1][d] == x}
    return closure(kernel, productions, first, nullable)  # @line L17


def build_tables(start: str, grammar: Sequence[Production]) -> LRTables:
    """Build the canonical LR(1) tables of a context-free grammar.

    grammar is a list of productions (A, (X1, ..., Xk)); the nonterminals are the left
    sides, every other symbol is a terminal. A conflict keeps the first action and is
    listed in tables.conflicts; the grammar is LR(1) when the list is empty (for a
    grammar without useless symbols). Cost: polynomial in the number of item sets,
    which can be exponential in |G|.
    """
    productions: list[Production] = [(START, (start,))] + [(a, tuple(r)) for a, r in grammar]
    nts = {a for a, _ in productions}
    first, nullable = first_sets(productions, nts)
    i0 = closure({(0, 0, END)}, productions, first, nullable)  # @line L1
    t = LRTables(productions, nts, [i0])
    index = {i0: 0}  # @line L2
    trans: dict[tuple[int, str], int] = {}
    i = 0
    while i < len(t.states):  # @line L3
        symbols: list[str] = []
        for p, d, _ in sorted(t.states[i]):
            rhs = productions[p][1]
            if d < len(rhs) and rhs[d] not in symbols:
                symbols.append(rhs[d])
        for x in symbols:  # @line L4
            j = goto_set(t.states[i], x, productions, first, nullable)  # @line L5
            if j not in index:  # @line L6
                index[j] = len(t.states)
                t.states.append(j)
            trans[(i, x)] = index[j]  # @line L7
        i += 1  # @line L8

    def put(key, act):
        if key in t.action and t.action[key] != act:
            if (key, t.action[key], act) not in t.conflicts:
                t.conflicts.append((key, t.action[key], act))
        else:
            t.action[key] = act

    for s, items in enumerate(t.states):  # @line L9
        for p, d, la in sorted(items):
            lhs, rhs = productions[p]
            if d < len(rhs) and rhs[d] not in nts:  # @line L10
                put((s, rhs[d]), ("shift", trans[(s, rhs[d])]))
            elif d == len(rhs) and p != 0:  # @line L11
                put((s, la), ("reduce", p))
            elif d == len(rhs):  # @line L12
                put((s, END), ("accept",))
    t.goto = {(s, x): v for (s, x), v in trans.items() if x in nts}  # @line L13
    return t


def parse(t: LRTables, tokens: Sequence[str]) -> list[int] | None:
    """Run the LR driver on tokens. Return the list of reductions (indices into the
    input grammar, from 0) in the order made, which is a rightmost derivation in
    reverse, or None when the input is rejected. Cost: O(n) steps for an LR(1) grammar.
    """
    toks = list(tokens) + [END]
    stack = [0]  # @line L18
    pos = 0
    out: list[int] = []
    while True:  # @line L19
        s = stack[-1]  # @line L20
        act = t.action.get((s, toks[pos]))
        if act is None:  # @line L24
            return None
        if act[0] == "shift":  # @line L21
            stack.append(act[1])
            pos += 1
        elif act[0] == "reduce":  # @line L22
            lhs, rhs = t.productions[act[1]]
            if rhs:
                del stack[-len(rhs):]
            stack.append(t.goto[(stack[-1], lhs)])
            out.append(act[1] - 1)
        else:  # @line L23
            return out
test_canonical_lr_parsing.py 179 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/canonical-lr-parsing/. The authors are listed in prov/12M6QZ.jsonl.

import itertools
import random

from canonical_lr_parsing import END, build_tables, parse


def g(text):
    """'S -> C C | d ; C -> c' into (start, productions); 'ε' is the empty string."""
    prods, start = [], None
    for part in text.split(";"):
        lhs, rhs = part.split("->")
        lhs = lhs.strip()
        start = start or lhs
        for alt in rhs.split("|"):
            prods.append((lhs, tuple(x for x in alt.split() if x != "ε")))
    return start, prods


def derives(prods, w):
    """Oracle: the least fixpoint of {(X, i, j) : X =>* w[i:j]}, exact for every grammar."""
    n = len(w)
    t = {(w[i], i, i + 1) for i in range(n)}
    changed = True
    while changed:
        changed = False
        for a, rhs in prods:
            for i in range(n + 1):
                cur = {i}
                for x in rhs:
                    cur = {j for p in cur for j in range(p, n + 1) if (x, p, j) in t}
                for j in cur:
                    if (a, i, j) not in t:
                        t.add((a, i, j))
                        changed = True
    return t


def trees(start, prods, w, cap=2):
    """Oracle: the number of parse trees of w, capped at cap (a cycle counts as cap)."""
    t = derives(prods, w)
    nts = {a for a, _ in prods}
    memo, active = {}, set()

    def cnt(x, i, j):
        if x not in nts:
            return 1 if j == i + 1 and w[i] == x else 0
        if (x, i, j) not in t:
            return 0
        if (x, i, j) in memo:
            return memo[(x, i, j)]
        if (x, i, j) in active:
            return cap
        active.add((x, i, j))
        total = sum(seq(rhs, 0, i, j) for a, rhs in prods if a == x)
        active.discard((x, i, j))
        memo[(x, i, j)] = min(total, cap)
        return memo[(x, i, j)]

    def seq(rhs, k, i, j):
        if k == len(rhs):
            return 1 if i == j else 0
        return sum(cnt(rhs[k], i, m) * seq(rhs, k + 1, m, j) for m in range(i, j + 1) if cnt(rhs[k], i, m))

    return cnt(start, 0, len(w))


def replay(start, prods, reductions, w):
    """Read the reductions backwards as a rightmost derivation and return its yield."""
    nts = {a for a, _ in prods}
    form = [start]
    for k in reversed(reductions):
        a, rhs = prods[k]
        pos = max(i for i, x in enumerate(form) if x in nts)
        assert form[pos] == a
        form[pos:pos + 1] = list(rhs)
    assert all(x not in nts for x in form)
    return "".join(form)


def words(alphabet, max_len):
    for k in range(max_len + 1):
        for t in itertools.product(alphabet, repeat=k):
            yield "".join(t)


def check(start, prods, alphabet, max_len):
    t = build_tables(start, prods)
    for w in words(alphabet, max_len):
        member = (start, 0, len(w)) in derives(prods, w)
        if t.conflicts:
            assert trees(start, prods, w) <= 2
            continue
        assert trees(start, prods, w) <= 1, "a conflict-free table proves the grammar unambiguous"
        out = parse(t, w)
        assert (out is not None) == member, (prods, w)
        if out is not None:
            assert replay(start, prods, out, w) == w
    return t


def test_dragon_book_example():
    start, prods = g("S -> C C ; C -> c C | d")
    t = check(start, prods, "cd", 7)
    assert len(t.states) == 10 and not t.conflicts
    # I3 = GOTO(I0, c) and I6 = GOTO(I2, c) have the same core but different lookaheads
    assert t.action[(0, "c")] == ("shift", 3) and t.action[(2, "c")] == ("shift", 6)
    assert t.action[(4, "c")] == ("reduce", 3) and t.action[(4, "d")] == ("reduce", 3)
    assert (4, END) not in t.action and t.action[(7, END)] == ("reduce", 3)
    assert t.goto[(0, "S")] == 1 and t.goto[(0, "C")] == 2 and t.goto[(6, "C")] == 9
    # cdd: shift c, shift d, C -> d, C -> c C, shift d, C -> d, S -> C C
    assert parse(t, "cdd") == [2, 1, 2, 0]
    assert parse(t, "cd") is None and parse(t, "ddd") is None


def test_expressions_left_recursion():
    start, prods = g("E -> E + T | T ; T -> T * F | F ; F -> ( E ) | i")
    t = check(start, prods, "i+*()", 5)
    assert len(t.states) == 22 and not t.conflicts
    out = parse(t, "i+i*i")
    assert [prods[k] for k in out] == [
        ("F", ("i",)), ("T", ("F",)), ("E", ("T",)), ("F", ("i",)), ("T", ("F",)),
        ("F", ("i",)), ("T", ("T", "*", "F")), ("E", ("E", "+", "T"))]


def test_lr1_not_lalr1():
    start, prods = g("S -> a A d | b B d | a B e | b A e ; A -> c ; B -> c")
    t = check(start, prods, "abcde", 4)
    assert not t.conflicts
    assert parse(t, "acd") == [4, 0] and parse(t, "ace") == [5, 2]


def test_ambiguous_grammars_conflict():
    for text in ["E -> E + E | a", "S -> i S | i S e S | a", "S -> S S | ( S ) | ε", "S -> A | B ; A -> a ; B -> a"]:
        start, prods = g(text)
        assert build_tables(start, prods).conflicts


def test_epsilon_and_empty_input():
    start, prods = g("S -> ε")
    t = check(start, prods, "a", 3)
    assert parse(t, "") == [0] and parse(t, "a") is None
    start, prods = g("S -> ( S ) S | ε")
    check(start, prods, "()", 8)
    start, prods = g("S -> A B ; A -> a A | ε ; B -> b B | ε")
    check(start, prods, "ab", 6)
    start, prods = g("S -> A ; A -> B ; B -> b")  # unit productions
    t = check(start, prods, "b", 3)
    assert parse(t, "b") == [2, 1, 0]


def test_long_input_no_recursion():
    start, prods = g("E -> E + T | T ; T -> i")
    t = build_tables(start, prods)
    n = 20000
    out = parse(t, "i" + "+i" * n)
    assert out is not None and len(out) == 1 + 2 * n + 1
    start, prods = g("E -> T + E | T ; T -> i")  # right recursion: the stack grows
    t = build_tables(start, prods)
    assert len(parse(t, "i" + "+i" * n)) == 2 * (n + 1)


def test_random_against_brute_force():
    rng = random.Random(1965)
    names = ["S", "A", "B"]
    conflict_free = 0
    for _ in range(700):
        k = rng.randint(1, 3)
        prods = []
        for a in names[:k]:
            for _ in range(rng.randint(1, 3)):
                rhs = tuple(rng.choice(names[:k] + ["a", "b"]) for _ in range(rng.randint(0, 3)))
                if (a, rhs) not in prods:
                    prods.append((a, rhs))
        t = check("S", prods, "ab", 5)
        conflict_free += not t.conflicts
    assert conflict_free > 100

What the next level needs

  • the tests did not run in this build

Read the full page about Canonical LR(1) parsing