Algorithm Search⌘K

Recursive descent parsing in Python

Top-down parser implemented as mutually recursive procedures, one per nonterminal.

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

"""A predictive recursive descent parser for an LL(1) grammar.

A grammar is a list of productions (A, rhs) with rhs a tuple of symbols; the
nonterminals are the left sides and every other symbol is a terminal. The
end marker "$" must not be a grammar symbol. A parse tree is (A, children),
where each child is a tree or a terminal.

Each nonterminal gets one procedure, PROC(A). The procedures are Python
generators: a call PROC(B) is a `yield B` that the driver answers with the
subtree of B. So the recursion of the method lives on an explicit list, and a
deeply nested input does not hit the Python recursion limit.
"""

from typing import Generator, Sequence, Union

Tree = tuple[str, tuple[Union["Tree", str], ...]]
Production = tuple[str, tuple[str, ...]]
END = "$"


class GrammarError(ValueError):
    """The grammar is not LL(1), or it is left recursive."""


class ParseError(ValueError):
    """The input is not in the language of the grammar."""


def _analyse(prods: Sequence[Production], start: str):
    nts = {a for a, _ in prods}
    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 prods:
            if a not in nullable and all(x in nullable for x in rhs):
                nullable.add(a)
                changed = 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:
                    break

    def first_of(seq: Sequence[str]) -> tuple[set[str], bool]:
        out: set[str] = set()
        for x in seq:
            out |= first[x] if x in nts else {x}
            if x not in nullable:
                return out, False
        return out, True

    follow: dict[str, set[str]] = {a: set() for a in nts}
    follow[start].add(END)
    changed = True
    while changed:
        changed = False
        for a, rhs in prods:
            for i, x in enumerate(rhs):
                if x in nts:
                    f, nul = first_of(rhs[i + 1:])
                    add = f | follow[a] if nul else f
                    if not add <= follow[x]:
                        follow[x] |= add
                        changed = True
    return nts, nullable, first_of, follow


class RecursiveDescentParser:
    """Build with an LL(1) grammar; then parse(tokens) returns the parse tree.

    Construction costs O(|V| * |Sigma| * |G|) set operations. A parse costs
    time linear in the size of the parse tree, which is O(n) for a fixed
    grammar and an input of n tokens.
    """

    def __init__(self, productions: Sequence[Production], start: str) -> None:
        self.prods = [(a, tuple(r)) for a, r in productions]
        self.start = start
        nts, nullable, first_of, follow = _analyse(self.prods, start)
        if start not in nts or END in {x for _, r in self.prods for x in r}:
            raise GrammarError("bad start symbol or end marker in the grammar")
        self.nts = nts
        # @line L1: PREDICT(A -> alpha) = FIRST(alpha), plus FOLLOW(A) if alpha is nullable
        self.table: dict[tuple[str, str], int] = {}
        for k, (a, rhs) in enumerate(self.prods):
            f, nul = first_of(rhs)
            for t in f | follow[a] if nul else f:
                if (a, t) in self.table:
                    raise GrammarError(f"not LL(1): two productions of {a} on {t!r}")
                self.table[(a, t)] = k
        # Left recursion through nullable prefixes: A -> beta B gamma with beta nullable.
        edges: dict[str, set[str]] = {a: set() for a in nts}
        for a, rhs in self.prods:
            for x in rhs:
                if x in nts:
                    edges[a].add(x)
                if x not in nullable:
                    break
        color = {a: 0 for a in nts}
        for root in nts:
            if color[root]:
                continue
            color[root] = 1
            path = [(root, iter(edges[root]))]
            while path:
                node, it = path[-1]
                nxt = next(it, None)
                if nxt is None:
                    color[node] = 2
                    path.pop()
                elif color[nxt] == 1:
                    raise GrammarError(f"left recursive at {nxt}")
                elif color[nxt] == 0:
                    color[nxt] = 1
                    path.append((nxt, iter(edges[nxt])))

    def parse(self, tokens: Sequence[str]) -> Tree:
        """Return the parse tree of tokens, or raise ParseError."""
        toks = list(tokens) + [END]
        pos = 0

        def proc(a: str) -> Generator[str, Tree, Tree]:
            nonlocal pos
            k = self.table.get((a, toks[pos]))  # @line L5
            if k is None:
                raise ParseError(f"no production of {a} on {toks[pos]!r} at {pos}")
            rhs = self.prods[k][1]
            children: list[Union[Tree, str]] = []  # @line L6
            for x in rhs:  # @line L7
                if x not in self.nts:  # @line L8
                    if toks[pos] != x:  # @line L9
                        raise ParseError(f"expected {x!r} at {pos}, found {toks[pos]!r}")
                    children.append(x)  # @line L10
                    pos += 1
                else:
                    children.append((yield x))  # @line L11
            return (a, tuple(children))  # @line L12

        stack = [proc(self.start)]  # @line L2
        value: Tree | None = None
        result: Tree | None = None
        while stack:
            try:
                callee = stack[-1].send(value)  # type: ignore[arg-type]
            except StopIteration as done:
                stack.pop()
                value = done.value
                result = value
                continue
            stack.append(proc(callee))
            value = None
        if toks[pos] != END:  # @line L3
            raise ParseError(f"extra input at {pos}")
        assert result is not None
        return result  # @line L4

    def recognize(self, tokens: Sequence[str]) -> bool:
        try:
            self.parse(tokens)
        except ParseError:
            return False
        return True
test_recursive_descent_parsing.py 203 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/recursive-descent-parsing/. The authors are listed in prov/FBHW41.jsonl.

import itertools
import random

import pytest

from recursive_descent_parsing import GrammarError, ParseError, RecursiveDescentParser

EXPR = [
    ("E", ("T", "E'")), ("E'", ("+", "T", "E'")), ("E'", ()),
    ("T", ("F", "T'")), ("T'", ("*", "F", "T'")), ("T'", ()),
    ("F", ("(", "E", ")")), ("F", ("a",)),
]


def spans(prods, w):
    """Independent oracle: the set of (A, i, j) with A =>* w[i:j], by a least fixpoint."""
    nts = {a for a, _ in prods}
    n = len(w)
    known = set()

    def ok(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 prods:
            for i in range(n + 1):
                for j in range(i, n + 1):
                    if (a, i, j) not in known and ok(rhs, i, j):
                        known.add((a, i, j))
                        changed = True
    return known


def derives(prods, start, w):
    return (start, 0, len(w)) in spans(prods, w)


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


def oracle_ll1(prods, start):
    """Independent LL(1) and left-recursion test written from the definitions."""
    nts = {a for a, _ in prods}
    nullable = {a for a in nts if derives(prods, a, ())}
    # left corners: A -> beta X ... with beta nullable
    corner = {a: set() for a in nts}
    for a, r in prods:
        for x in r:
            corner[a].add(x)
            if x not in nullable:
                break
    reach = {}
    for a in nts:
        seen, todo = set(), [a]
        while todo:
            x = todo.pop()
            for y in corner.get(x, ()):
                if y not in seen:
                    seen.add(y)
                    todo.append(y)
        reach[a] = seen
    if any(a in reach[a] for a in nts):
        return False
    first = {a: {t for t in reach[a] if t not in nts} for a in nts}

    def fst(seq):
        out = set()
        for x in seq:
            out |= first[x] if x in nts else {x}
            if x not in nullable:
                return out, False
        return out, True

    follow = {a: set() for a in nts}
    follow[start].add("$")
    for _ in range(len(nts) + 2):
        for a, r in prods:
            for i, x in enumerate(r):
                if x in nts:
                    f, nul = fst(r[i + 1:])
                    follow[x] |= f
                    if nul:
                        follow[x] |= follow[a]
    for a in nts:
        mine = [r for b, r in prods if b == a]
        sets = []
        for r in mine:
            f, nul = fst(r)
            sets.append(f | follow[a] if nul else f)
        for s1, s2 in itertools.combinations(sets, 2):
            if s1 & s2:
                return False
    return True


def random_grammar(rng):
    names = ["S", "A", "B", "C"][: rng.randint(1, 4)]
    terms = ["a", "b", "c"]
    prods = []
    for a in names:
        for _ in range(rng.randint(1, 3)):
            k = rng.randint(0, 3)
            prods.append((a, tuple(rng.choice(names + terms + terms) for _ in range(k))))
    out = []
    for p in prods:
        if p not in out:
            out.append(p)
    return out


def test_example() -> None:
    p = RecursiveDescentParser(EXPR, "E")
    toks = "a + a * a".split()
    t = p.parse(toks)
    assert tree_ok(t, EXPR, toks)
    assert t[0] == "E" and t[1][0] == ("T", (("F", ("a",)), ("T'", ())))
    assert not p.recognize("a + * a".split())
    assert not p.recognize("( a".split())
    assert not p.recognize([])
    assert p.recognize("( ( a ) ) * a + a".split())


def test_grammar_errors() -> None:
    with pytest.raises(GrammarError):
        RecursiveDescentParser([("E", ("E", "+", "a")), ("E", ("a",))], "E")  # left recursion
    with pytest.raises(GrammarError):
        RecursiveDescentParser([("S", ("a", "b")), ("S", ("a", "c"))], "S")  # common prefix
    with pytest.raises(GrammarError):
        RecursiveDescentParser([("S", ("S", "S")), ("S", ("a",)), ("S", ())], "S")  # ambiguous
    with pytest.raises(GrammarError):
        RecursiveDescentParser([("S", ("A", "a")), ("A", ("a",)), ("A", ())], "S")  # FIRST/FOLLOW clash


def test_empty_input_and_epsilon() -> None:
    p = RecursiveDescentParser([("S", ("a", "S", "b")), ("S", ())], "S")
    assert p.parse([]) == ("S", ())
    assert p.recognize(list("aaabbb"))
    assert not p.recognize(list("aab"))
    unit = RecursiveDescentParser([("S", ("A",)), ("A", ("B",)), ("B", ("b",))], "S")
    assert unit.parse(["b"]) == ("S", (("A", (("B", ("b",)),)),))


def test_random_grammars_against_oracle() -> None:
    rng = random.Random(1968)
    accepted = 0
    tried = 0
    while accepted < 500:
        tried += 1
        g = random_grammar(rng)
        start = g[0][0]
        expect = oracle_ll1(g, start)
        try:
            p = RecursiveDescentParser(g, start)
        except GrammarError:
            assert not expect, g
            continue
        assert expect, g
        accepted += 1
        for n in range(0, 5):
            for w in itertools.product("abc", repeat=n):
                w = list(w)
                want = derives(g, start, w)
                try:
                    t = p.parse(w)
                except ParseError:
                    assert not want, (g, w)
                    continue
                assert want and tree_ok(t, g, w), (g, w)
    assert tried > accepted


def test_deep_nesting() -> None:
    p = RecursiveDescentParser(EXPR, "E")
    n = 5000
    toks = ["("] * n + ["a"] + [")"] * n
    assert p.recognize(toks)
    assert not p.recognize(toks[:-1])

What the next level needs

  • the tests did not run in this build

Read the full page about Recursive descent parsing