Algorithm Search⌘K

Generalized LR parsing in Python

Extends LR parsing to any context-free grammar by forking the parser on conflicts.

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

"""Generalized LR (GLR) recognizer and parser for any context-free grammar.

A grammar is a list of productions (A, rhs), where rhs is a tuple of symbols.
The nonterminals are the left sides and the start symbol; every other symbol
is a terminal. Empty right sides, unit productions, left recursion, hidden
left recursion, cycles and ambiguity are all allowed.

The parser runs the LR(0) automaton of the grammar on all branches at once.
The branches share one graph-structured stack: a node is a pair (state,
level), where the level is the number of input symbols read when the node was
pushed, and an edge v -> x means that the cell v lies directly on the cell x.
Every edge carries one parse tree for the grammar symbol that the edge stands
for, so an accepting edge gives one parse tree of the input. A parse tree is
(A, children), where each child is a tree or a terminal symbol.
"""

from typing import Optional, Sequence, Union

Tree = tuple[str, tuple[Union["Tree", str], ...]]
Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int]
Edge = tuple[int, int]


class Lr0Automaton:
    """The LR(0) automaton of the grammar augmented with S' -> S.

    Production 0 of `prods` is the augmenting production; production p >= 1
    is productions[p - 1]. `goto[q]` maps a symbol to the next state, and
    `reductions[q]` lists the productions p >= 1 with a complete item in q.
    """

    def __init__(self, prods: list[Production], states: list[frozenset[Item]],
                 goto: list[dict[str, int]], reductions: list[list[int]]) -> None:
        self.prods = prods
        self.states = states
        self.goto = goto
        self.reductions = reductions


def lr0_automaton(productions: Sequence[Production], start: str) -> Lr0Automaton:
    """Build the canonical LR(0) automaton by closure and goto."""
    prods: list[Production] = [("S'", (start,))] + [(a, tuple(r)) for a, r in productions]
    nts = {a for a, _ in productions} | {start}
    by_lhs: dict[str, list[int]] = {}
    for p, (a, _) in enumerate(prods):
        if p > 0:
            by_lhs.setdefault(a, []).append(p)

    def closure(kernel: set[Item]) -> frozenset[Item]:
        items = set(kernel)
        todo = list(kernel)
        while todo:
            p, d = todo.pop()
            rhs = prods[p][1]
            if d < len(rhs) and rhs[d] in nts:
                for q in by_lhs.get(rhs[d], []):
                    if (q, 0) not in items:
                        items.add((q, 0))
                        todo.append((q, 0))
        return frozenset(items)

    states = [closure({(0, 0)})]
    index = {states[0]: 0}
    goto: list[dict[str, int]] = []
    k = 0
    while k < len(states):
        moves: dict[str, set[Item]] = {}
        for p, d in states[k]:
            rhs = prods[p][1]
            if d < len(rhs):
                moves.setdefault(rhs[d], set()).add((p, d + 1))
        row: dict[str, int] = {}
        for x, kernel in moves.items():
            target = closure(kernel)
            if target not in index:
                index[target] = len(states)
                states.append(target)
            row[x] = index[target]
        goto.append(row)
        k += 1
    reductions = [sorted(p for p, d in s if p > 0 and d == len(prods[p][1])) for s in states]
    return Lr0Automaton(prods, states, goto, reductions)


def glr_parse(productions: Sequence[Production], start: str, w: Sequence[str]) -> Optional[Tree]:
    """Return one parse tree of w from start, or None if w is not in the language."""
    aut = lr0_automaton(productions, start)                              # @line L1
    prods, goto, red = aut.prods, aut.goto, aut.reductions
    nts = {a for a, _ in productions} | {start}
    n = len(w)
    node_of: dict[tuple[int, int], int] = {}
    state: list[int] = []
    level: list[int] = []
    out: list[dict[int, Union[Tree, str]]] = []   # out[v][x] = tree on the edge v -> x

    def new_node(s: int, j: int) -> int:
        node_of[(s, j)] = len(state)
        state.append(s)
        level.append(j)
        out.append({})
        return len(state) - 1

    root = new_node(0, 0)                                                # @line L2
    frontier = [root]
    for j in range(n + 1):                                               # @line L3
        queue: list[tuple[int, int, Optional[Edge]]] = [
            (u, p, None) for u in frontier for p in red[state[u]]]       # @line L4
        while queue:                                                     # @line L5
            u, p, e = queue.pop()                                        # @line L6
            a, rhs = prods[p]
            for x, kids in _paths(out, level, u, len(rhs), e, j):        # @line L7
                tree: Tree = (a, tuple(kids))
                key = (goto[state[x]][a], j)
                v = node_of.get(key)
                if v is None:                                            # @line L8
                    v = new_node(*key)
                    frontier.append(v)
                    queue.extend((v, q, None) for q in red[key[0]])
                elif x not in out[v]:                                    # @line L9
                    queue.extend((u2, q, (v, x)) for u2 in frontier
                                 for q in red[state[u2]] if prods[q][1])
                if x not in out[v]:                                      # @line L10
                    out[v][x] = tree
        if j < n:                                                        # @line L11
            nxt: list[int] = []
            a = w[j]
            for u in frontier:
                s = goto[state[u]].get(a) if a not in nts else None
                if s is None:
                    continue
                v = node_of.get((s, j + 1))                              # @line L12
                if v is None:
                    v = new_node(s, j + 1)
                    nxt.append(v)
                out[v][u] = a
            frontier = nxt
    acc = goto[0].get(start)                                             # @line L13
    top = node_of.get((acc, n)) if acc is not None else None
    if top is None or root not in out[top]:
        return None
    tree = out[top][root]
    return tree if isinstance(tree, tuple) else None


def glr_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
    """Return True if w is in the language of the grammar."""
    return glr_parse(productions, start, w) is not None


def _paths(out: list[dict[int, Union[Tree, str]]], level: list[int], u: int, m: int,
           through: Optional[Edge], j: int) -> list[tuple[int, list[Union[Tree, str]]]]:
    """All paths of m edges from u, as (end node, edge trees from bottom to top).

    If `through` is an edge (v, x) whose source is at level j, only paths that
    use it are returned. Such a path stays at level j until it uses the edge,
    because edges never go up a level, so a path that leaves level j first is
    cut off. The search keeps its own stack and does not recurse.
    """
    result: list[tuple[int, list[Union[Tree, str]]]] = []
    stack: list[tuple[int, int, bool, list[Union[Tree, str]]]] = [(u, 0, through is None, [])]
    while stack:
        node, depth, used, trees = stack.pop()
        if depth == m:
            if used:
                result.append((node, trees[::-1]))
            continue
        if not used and level[node] < j:
            continue
        for x, t in out[node].items():
            stack.append((x, depth + 1, used or (node, x) == through, trees + [t]))
    return result
test_glr_parsing.py 135 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/glr-parsing/. The authors are listed in prov/Z0RM8G.jsonl.

import itertools
import random

from glr_parsing import glr_parse, glr_recognize, lr0_automaton

EXAMPLE = [("S", ("S", "S")), ("S", ("a",))]


def derives(productions, start, w):
    """Independent oracle: least fixpoint of 'A derives w[i:j]' over all spans."""
    nts = {a for a, _ in productions} | {start}
    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 check_tree(productions, start, w, tree):
    """Check without recursion that tree is a parse tree of w from start."""
    prods = {(a, tuple(r)) for a, r in productions}
    assert tree[0] == start
    leaves = []
    stack = [tree]
    while stack:
        t = stack.pop()
        if isinstance(t, str):
            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 prods, (a, labels)
        stack.extend(reversed(kids))
    assert leaves == list(w)


def random_grammar(rng):
    nts = ["S", "A", "B"][: rng.randint(1, 3)]
    syms = nts + ["a", "b"]
    prods = []
    for a in nts:
        for _ in range(rng.randint(1, 3)):
            k = rng.choice([0, 1, 1, 2, 2, 3])
            prods.append((a, tuple(rng.choice(syms) for _ in range(k))))
    if rng.random() < 0.3:                      # force left recursion
        prods.append(("S", ("S", rng.choice(syms))))
    if rng.random() < 0.2:                      # hidden left recursion and cycles
        prods.append(("S", (rng.choice(nts), "S")))
    return prods


def test_example():
    for k in range(1, 7):
        w = ["a"] * k
        tree = glr_parse(EXAMPLE, "S", w)
        check_tree(EXAMPLE, "S", w, tree)
    assert glr_parse(EXAMPLE, "S", []) is None
    assert glr_parse(EXAMPLE, "S", ["b"]) is None


def test_example_automaton():
    aut = lr0_automaton(EXAMPLE, "S")
    assert len(aut.states) == 4
    conflict = [q for q, s in enumerate(aut.states) if aut.reductions[q] and "a" in aut.goto[q]]
    assert len(conflict) == 1


def test_empty_input_and_epsilon():
    g = [("S", ()), ("S", ("A", "S")), ("A", ())]
    assert glr_recognize(g, "S", [])
    assert not glr_recognize(g, "S", ["a"])
    g2 = [("S", ("A", "S", "b")), ("S", ("a",)), ("A", ())]
    for k in range(5):
        w = ["a"] + ["b"] * k
        assert glr_recognize(g2, "S", w)
        check_tree(g2, "S", w, glr_parse(g2, "S", w))
    assert not glr_recognize(g2, "S", ["b"])


def test_cycles_and_units():
    g = [("S", ("S",)), ("S", ("A",)), ("A", ("S",)), ("A", ("a",)), ("S", ("S", "S"))]
    for k in range(5):
        assert glr_recognize(g, "S", ["a"] * k) == (k >= 1)


def test_start_without_productions():
    assert not glr_recognize([("A", ("a",))], "S", ["a"])
    assert not glr_recognize([], "S", [])


def test_random_against_oracle():
    rng = random.Random(20261106)
    inputs = 0
    for _ in range(250):
        g = random_grammar(rng)
        words = [list(t) for m in range(4) for t in itertools.product("ab", repeat=m)]
        words += [[rng.choice("ab") for _ in range(rng.randint(4, 7))] for _ in range(3)]
        for w in words:
            expect = derives(g, "S", w)
            tree = glr_parse(g, "S", w)
            assert (tree is not None) == expect, (g, w)
            if tree is not None:
                check_tree(g, "S", w, tree)
            inputs += 1
    assert inputs >= 500


def test_long_input_no_recursion_limit():
    g = [("S", ("S", "a")), ("S", ("a", "S")), ("S", ())]
    w = ["a"] * 300
    tree = glr_parse(g, "S", w)
    check_tree(g, "S", w, tree)

What the next level needs

  • the tests did not run in this build

Read the full page about Generalized LR parsing