Algorithm Search⌘K

Parsing table in Python

The table that maps a parser state and lookahead symbol to a shift, reduce, goto, accept or error action.

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

"""A parsing table: a dense grid of cells indexed by (row, column) that records conflicts.

The same structure holds an LR table (rows are automaton states, columns are the
terminals, the end marker and the nonterminals) and an LL(1) table (rows are the
nonterminals, columns are the terminals and the end marker). A cell holds the
actions added to it, in the order they were added; a cell with two different
actions is a conflict.

Actions are tuples:
  ("shift", q), ("reduce", p), ("goto", q), ("accept",) for LR tables,
  ("predict", p) for LL tables, where p is a production index.

A grammar is a list of productions (A, rhs) with rhs a tuple of symbols; the left
sides are the nonterminals, every other symbol is a terminal, and the first
production's left side is the start symbol.
"""

from collections.abc import Callable, Hashable, Sequence

END = "$"
Action = tuple
Production = tuple[str, tuple[str, ...]]


class ParsingTable:
    """Rows of cells over a fixed list of column symbols, with conflict tracking."""

    def __init__(self, columns: Sequence[Hashable], rows: int = 0) -> None:
        self.columns = list(columns)
        self.index = {c: i for i, c in enumerate(self.columns)}
        if len(self.index) != len(self.columns):
            raise ValueError("column symbols must be distinct")
        self.cells: list[list[tuple[Action, ...]]] = []
        self.conflicting: set[tuple[int, int]] = set()
        for _ in range(rows):
            self.add_row()

    def add_row(self) -> int:
        """Append an empty row and return its number."""
        self.cells.append([() for _ in self.columns])
        return len(self.cells) - 1

    @property
    def rows(self) -> int:
        return len(self.cells)

    def add(self, row: int, column: Hashable, action: Action) -> None:
        """Put action into the cell; a second, different action makes the cell a conflict."""
        c = self.index[column]
        cell = self.cells[row][c]
        if not cell:  # @line L1
            self.cells[row][c] = (action,)
        elif action not in cell:  # @line L2
            self.cells[row][c] = cell + (action,)
            self.conflicting.add((row, c))

    def lookup(self, row: int, column: Hashable) -> tuple[Action, ...]:
        """The actions of the cell, in insertion order; () means error."""
        c = self.index.get(column)
        if c is None or not 0 <= row < len(self.cells):
            return ()
        return self.cells[row][c]  # @line L3

    def conflicts(self) -> list[tuple[int, Hashable, tuple[Action, ...]]]:
        """The conflicting cells as (row, column symbol, actions), sorted by position."""
        return [(r, self.columns[c], self.cells[r][c]) for r, c in sorted(self.conflicting)]

    def resolve(self, choose: Callable[[int, Hashable, tuple[Action, ...]], Action | None]) -> None:
        """Replace each conflicting cell by the single action choose returns (None empties it)."""
        for r, c in sorted(self.conflicting):  # @line L4
            pick = choose(r, self.columns[c], self.cells[r][c])
            self.cells[r][c] = () if pick is None else (pick,)  # @line L5
        self.conflicting.clear()  # @line L6

    def entries(self) -> int:
        """The number of (cell, action) pairs stored."""
        return sum(len(cell) for row in self.cells for cell in row)


def _symbols(grammar: Sequence[Production]) -> tuple[list[str], list[str]]:
    nts: list[str] = []
    for a, _ in grammar:
        if a not in nts:
            nts.append(a)
    terms: list[str] = []
    for _, rhs in grammar:
        for x in rhs:
            if x not in nts and x not in terms:
                terms.append(x)
    return nts, terms


def first_follow(grammar: Sequence[Production]) -> tuple[set[str], dict[str, set[str]], dict[str, set[str]]]:
    """Nullable nonterminals, FIRST and FOLLOW sets, by iteration to a fixed point."""
    nts, _ = _symbols(grammar)
    ntset = set(nts)
    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 grammar:
            if a not in nullable and all(x in nullable for x in rhs):
                nullable.add(a)
                changed = True
            for x in rhs:
                add = first[x] if x in ntset else {x}
                if not add <= first[a]:
                    first[a] |= add
                    changed = True
                if x not in nullable:
                    break
    follow: dict[str, set[str]] = {a: set() for a in nts}
    follow[nts[0]].add(END)
    changed = True
    while changed:
        changed = False
        for a, rhs in grammar:
            for i, x in enumerate(rhs):
                if x not in ntset:
                    continue
                add: set[str] = set()
                rest_nullable = True
                for y in rhs[i + 1:]:
                    add |= first[y] if y in ntset else {y}
                    if y not in nullable:
                        rest_nullable = False
                        break
                if rest_nullable:
                    add |= follow[a]
                if not add <= follow[x]:
                    follow[x] |= add
                    changed = True
    return nullable, first, follow


def ll1_table(grammar: Sequence[Production]) -> ParsingTable:
    """The LL(1) table: row i is the i-th nonterminal; conflicts are recorded, not resolved."""
    nts, terms = _symbols(grammar)
    ntset = set(nts)
    nullable, first, follow = first_follow(grammar)
    table = ParsingTable(terms + [END], rows=len(nts))
    row = {a: i for i, a in enumerate(nts)}
    for p, (a, rhs) in enumerate(grammar):
        predict: set[str] = set()
        all_nullable = True
        for x in rhs:
            predict |= first[x] if x in ntset else {x}
            if x not in nullable:
                all_nullable = False
                break
        if all_nullable:
            predict |= follow[a]
        for t in sorted(predict):
            table.add(row[a], t, ("predict", p))
    return table


def slr_table(grammar: Sequence[Production]) -> tuple[ParsingTable, list[Production]]:
    """The SLR(1) table over the LR(0) automaton of the augmented grammar.

    Returns the table and the augmented production list, whose production 0 is
    S' -> S. Columns are the terminals, the end marker and the nonterminals.
    """
    nts, terms = _symbols(grammar)
    start = nts[0]
    aug_name = start + "'"
    while aug_name in nts or aug_name in terms:
        aug_name += "'"
    prods: list[Production] = [(aug_name, (start,))] + list(grammar)
    ntset = set(nts) | {aug_name}
    _, _, follow = first_follow(prods)
    order: list[str] = []
    for _, rhs in prods:
        for x in rhs:
            if x not in order:
                order.append(x)

    def closure(items: frozenset[tuple[int, int]]) -> frozenset[tuple[int, int]]:
        out = set(items)
        todo = list(items)
        while todo:
            p, d = todo.pop()
            rhs = prods[p][1]
            if d < len(rhs) and rhs[d] in ntset:
                for j, (b, _) in enumerate(prods):
                    if b == rhs[d] and (j, 0) not in out:
                        out.add((j, 0))
                        todo.append((j, 0))
        return frozenset(out)

    table = ParsingTable(terms + [END] + nts)
    kernels = [frozenset({(0, 0)})]
    number = {kernels[0]: 0}
    table.add_row()
    q = 0
    while q < len(kernels):
        items = closure(kernels[q])
        for x in order:
            nxt = frozenset((p, d + 1) for p, d in items if d < len(prods[p][1]) and prods[p][1][d] == x)
            if not nxt:
                continue
            if nxt not in number:
                number[nxt] = len(kernels)
                kernels.append(nxt)
                table.add_row()
            target = number[nxt]
            table.add(q, x, ("goto", target) if x in ntset else ("shift", target))
        for p, d in sorted(items):
            if d == len(prods[p][1]):
                if p == 0:
                    table.add(q, END, ("accept",))
                else:
                    for t in sorted(follow[prods[p][0]]):
                        table.add(q, t, ("reduce", p))
        q += 1
    return table, prods


class StepLimit(Exception):
    """The driver ran more steps than allowed (possible only after conflicts were resolved)."""


def _single(actions: tuple[Action, ...]) -> Action | None:
    if not actions:
        return None
    if len(actions) > 1:
        raise ValueError("the table still has a conflict in this cell; resolve it first")
    return actions[0]


def lr_parse(table: ParsingTable, prods: Sequence[Production], tokens: Sequence[str],
             max_steps: int | None = None) -> bool:
    """Run the LR driver on tokens; True when the table accepts them."""
    stack = [0]  # @line L7
    toks = list(tokens) + [END]
    i = 0
    steps = 0
    while True:
        steps += 1
        if max_steps is not None and steps > max_steps:
            raise StepLimit(f"more than {max_steps} steps")
        act = _single(table.lookup(stack[-1], toks[i]))  # @line L8
        if act is None:
            return False
        if act[0] == "shift":  # @line L9
            stack.append(act[1])
            i += 1
        elif act[0] == "reduce":  # @line L10
            a, rhs = prods[act[1]]
            if rhs:
                del stack[-len(rhs):]
            go = _single(table.lookup(stack[-1], a))
            if go is None:
                return False
            stack.append(go[1])
        else:  # @line L11
            return True


def ll_parse(table: ParsingTable, grammar: Sequence[Production], tokens: Sequence[str],
             max_steps: int | None = None) -> bool:
    """Run the LL(1) driver on tokens; True when the table accepts them."""
    nts, _ = _symbols(grammar)
    row = {a: i for i, a in enumerate(nts)}
    stack = [END, nts[0]]
    toks = list(tokens) + [END]
    i = 0
    steps = 0
    while True:
        steps += 1
        if max_steps is not None and steps > max_steps:
            raise StepLimit(f"more than {max_steps} steps")
        top = stack.pop()
        if top in row:
            act = _single(table.lookup(row[top], toks[i]))
            if act is None:
                return False
            stack.extend(reversed(grammar[act[1]][1]))
        elif top == toks[i]:
            if top == END:
                return True
            i += 1
        else:
            return False
test_parsing_table.py 266 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/parsing-table/. The authors are listed in prov/JQ131J.jsonl.

import itertools
import random

import pytest

from parsing_table import (
    END,
    ParsingTable,
    StepLimit,
    ll1_table,
    ll_parse,
    lr_parse,
    slr_table,
)

LR_EXAMPLE = [("E", ("E", "+", "n")), ("E", ("n",))]
LL_EXAMPLE = [("E", ("n", "E'")), ("E'", ("+", "n", "E'")), ("E'", ())]
AMBIGUOUS = [("E", ("E", "+", "E")), ("E", ("n",))]


def cells(table):
    return {(r, c): table.lookup(r, c) for r in range(table.rows) for c in table.columns if table.lookup(r, c)}


# ---------------------------------------------------------------- oracles


def lang_upto(grammar, max_len):
    """For each nonterminal, every terminal string of length <= max_len it derives."""
    nts = {a for a, _ in grammar}
    lang = {a: set() for a in nts}
    changed = True
    while changed:
        changed = False
        for a, rhs in grammar:
            cur = {()}
            for x in rhs:
                src = lang[x] if x in nts else {(x,)}
                cur = {u + v for u in cur for v in src if len(u) + len(v) <= max_len}
                if not cur:
                    break
            if not cur <= lang[a]:
                lang[a] |= cur
                changed = True
    return lang


def tree_counts(grammar, max_len):
    """Number of parse trees, capped at 2, of each (nonterminal, string) with |string| <= max_len."""
    nts = {a for a, _ in grammar}
    strings = lang_upto(grammar, max_len)
    count = {(a, w): 0 for a in nts for w in strings[a]}

    def seq_count(rhs, w):
        # number of ways rhs derives w, using the current counts; capped at 2
        ways = {0: 1}
        for x in rhs:
            nxt = {}
            for start, c in ways.items():
                for end in range(start, len(w) + 1):
                    piece = w[start:end]
                    if x in nts:
                        k = count.get((x, piece), 0)
                    else:
                        k = 1 if piece == (x,) else 0
                    if k:
                        nxt[end] = min(2, nxt.get(end, 0) + c * k)
            ways = nxt
        return ways.get(len(w), 0)

    changed = True
    while changed:
        changed = False
        for a in nts:
            for w in strings[a]:
                total = min(2, sum(seq_count(rhs, w) for b, rhs in grammar if b == a))
                if total > count[(a, w)]:
                    count[(a, w)] = total
                    changed = True
    return count


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


def all_strings(terms, max_len):
    for n in range(max_len + 1):
        yield from itertools.product(terms, repeat=n)


# ---------------------------------------------------------------- worked example


def test_worked_example_lr_table():
    table, prods = slr_table(LR_EXAMPLE)
    assert prods[1:] == LR_EXAMPLE
    assert table.columns == ["+", "n", END, "E"]
    assert cells(table) == {
        (0, "n"): (("shift", 2),),
        (0, "E"): (("goto", 1),),
        (1, "+"): (("shift", 3),),
        (1, END): (("accept",),),
        (2, "+"): (("reduce", 2),),
        (2, END): (("reduce", 2),),
        (3, "n"): (("shift", 4),),
        (4, "+"): (("reduce", 1),),
        (4, END): (("reduce", 1),),
    }
    assert table.entries() == 9
    assert table.conflicts() == []
    assert lr_parse(table, prods, ["n", "+", "n"])
    assert not lr_parse(table, prods, ["n", "+"])
    assert not lr_parse(table, prods, [])


def test_worked_example_ll_table():
    table = ll1_table(LL_EXAMPLE)
    assert table.columns == ["n", "+", END]
    assert cells(table) == {
        (0, "n"): (("predict", 0),),
        (1, "+"): (("predict", 1),),
        (1, END): (("predict", 2),),
    }
    assert ll_parse(table, LL_EXAMPLE, ["n", "+", "n", "+", "n"])
    assert not ll_parse(table, LL_EXAMPLE, ["n", "n"])


def test_conflict_and_resolution():
    table, prods = slr_table(AMBIGUOUS)
    assert table.conflicts() == [(4, "+", (("shift", 3), ("reduce", 1)))]
    with pytest.raises(ValueError):
        lr_parse(table, prods, ["n", "+", "n", "+", "n"])
    table.resolve(lambda r, c, acts: next(a for a in acts if a[0] == "reduce"))
    assert table.conflicts() == []
    assert table.lookup(4, "+") == (("reduce", 1),)
    assert lr_parse(table, prods, ["n", "+", "n", "+", "n"])


# ---------------------------------------------------------------- edge cases


def test_empty_input_and_epsilon():
    g = [("S", ("a", "S")), ("S", ())]
    t, prods = slr_table(g)
    assert t.conflicts() == []
    assert lr_parse(t, prods, [])
    assert lr_parse(t, prods, ["a", "a"])
    lt = ll1_table(g)
    assert lt.conflicts() == []
    assert ll_parse(lt, g, [])
    assert not ll_parse(lt, g, ["b"])


def test_only_epsilon():
    g = [("S", ())]
    t, prods = slr_table(g)
    assert lr_parse(t, prods, [])
    assert not lr_parse(t, prods, ["x"])
    assert ll_parse(ll1_table(g), g, [])


def test_basic_operations():
    t = ParsingTable(["a", "b"], rows=2)
    assert t.lookup(0, "a") == ()
    assert t.lookup(5, "a") == ()
    assert t.lookup(0, "zzz") == ()
    t.add(0, "a", ("shift", 1))
    t.add(0, "a", ("shift", 1))
    assert t.conflicts() == []
    t.add(0, "a", ("reduce", 2))
    assert t.conflicts() == [(0, "a", (("shift", 1), ("reduce", 2)))]
    t.resolve(lambda r, c, acts: None)
    assert t.lookup(0, "a") == ()
    assert t.add_row() == 2
    with pytest.raises(ValueError):
        ParsingTable(["a", "a"])


def test_left_recursion_is_an_ll_conflict():
    g = [("E", ("E", "+", "n")), ("E", ("n",))]
    t = ll1_table(g)
    assert [(r, c) for r, c, _ in t.conflicts()] == [(0, "n")]


# ---------------------------------------------------------------- random


def test_random_operations_match_a_dictionary():
    rng = random.Random(1)
    for _ in range(600):
        cols = ["a", "b", "c", END]
        t = ParsingTable(cols, rows=3)
        oracle = {}
        for _ in range(rng.randint(0, 12)):
            r = rng.randrange(3)
            c = rng.choice(cols)
            act = (rng.choice(["shift", "reduce"]), rng.randrange(3))
            t.add(r, c, act)
            cell = oracle.setdefault((r, c), [])
            if act not in cell:
                cell.append(act)
        for r in range(3):
            for c in cols:
                assert t.lookup(r, c) == tuple(oracle.get((r, c), []))
        expect = sorted((r, cols.index(c)) for (r, c), v in oracle.items() if len(v) > 1)
        assert [(r, cols.index(c)) for r, c, _ in t.conflicts()] == expect
        assert t.entries() == sum(len(v) for v in oracle.values())
        t.resolve(lambda r, c, acts: acts[-1])
        for (r, c), v in oracle.items():
            assert t.lookup(r, c) == (v[-1],)


def test_random_grammars_against_brute_force():
    rng = random.Random(7)
    max_len = 4
    checked = 0
    for _ in range(700):
        g, terms = random_grammar(rng)
        lang = lang_upto(g, max_len)[g[0][0]]
        counts = tree_counts(g, max_len)
        ambiguous = any(v > 1 for (a, _), v in counts.items() if a == g[0][0])
        lr_t, prods = slr_table(g)
        ll_t = ll1_table(g)
        if ambiguous:
            # an ambiguous grammar is neither SLR(1) nor LL(1): both builders must report a conflict
            assert lr_t.conflicts() and ll_t.conflicts()
        if not lr_t.conflicts():
            for w in all_strings(terms, max_len):
                assert lr_parse(lr_t, prods, w, max_steps=1000) == (w in lang), (g, w)
                checked += 1
        else:
            lr_t.resolve(lambda r, c, acts: rng.choice(acts))
            for w in all_strings(terms, max_len):
                try:
                    if lr_parse(lr_t, prods, w, max_steps=200):
                        assert w in lang, (g, w)
                except StepLimit:
                    pass
        if not ll_t.conflicts():
            for w in all_strings(terms, max_len):
                assert ll_parse(ll_t, g, w, max_steps=1000) == (w in lang), (g, w)
                checked += 1
        else:
            ll_t.resolve(lambda r, c, acts: rng.choice(acts))
            for w in all_strings(terms, max_len):
                try:
                    if ll_parse(ll_t, g, w, max_steps=200):
                        assert w in lang, (g, w)
                except StepLimit:
                    pass
    assert checked >= 500

What the next level needs

  • the tests did not run in this build

Read the full page about Parsing table