Algorithm Search⌘K

LL(*) parsing in Python

Top-down parsing with arbitrary-length lookahead decided by DFA simulation, used in ANTLR.

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

"""Top-down parsing with adaptive LL(*) prediction, in the style of ALL(*).

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.

At a nonterminal with several productions the parser runs all of them in parallel
on the coming tokens, as sets of configurations (alternative, stack), until only
one alternative is left. It first uses only the stack inside the decision rule
(SLL prediction, cached as a DFA for each nonterminal); when that stack runs out
while several alternatives remain, it repeats the prediction with the real parser
stack (LL prediction). When the alternatives can no longer be told apart, the
grammar is ambiguous there and the lowest-numbered alternative wins.

The grammar must not be left-recursive. Productions that use a nonterminal that
derives no terminal string are dropped; they cannot occur in any parse tree.
"""

Production = tuple[str, tuple[str, ...]]
Tree = tuple[str, list] | str


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


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 _left_recursive(productions: list[Production], nts: set[str]) -> bool:
    """True if some A derives A gamma in one or more steps."""
    nullable: set[str] = set()
    changed = True
    while changed:
        changed = False
        for a, rhs in productions:
            if a not in nullable and all(x in nullable for x in rhs):
                nullable.add(a)
                changed = True
    edges: dict[str, set[str]] = {a: set() for a in nts}
    for a, rhs in productions:
        for x in rhs:
            if x not in nts:
                break
            edges[a].add(x)
            if x not in nullable:
                break
    color = dict.fromkeys(nts, 0)
    for root in nts:
        if color[root]:
            continue
        color[root] = 1
        work = [(root, iter(edges[root]))]
        while work:
            a, it = work[-1]
            b = next(it, None)
            if b is None:
                color[a] = 2
                work.pop()
            elif color[b] == 1:
                return True
            elif color[b] == 0:
                color[b] = 1
                work.append((b, iter(edges[b])))
    return False


def _starts_nt(st: tuple[str, ...], nts: set[str]) -> bool:
    return bool(st) and st[0] in nts


class AllStarParser:
    """An LL(*) parser for one grammar; the SLL DFA cache persists across inputs."""

    def __init__(self, productions: list[Production], start: str) -> None:
        self.productions = _productive(list(productions))
        self.start = start
        self.nts = {a for a, _ in productions} | {start}
        if _left_recursive(self.productions, self.nts):
            raise ValueError("the grammar is left-recursive")
        self.alts: dict[str, list[int]] = {a: [] for a in self.nts}
        for p, (a, _) in enumerate(self.productions):
            self.alts[a].append(p)
        # SLL DFA: for each nonterminal, its start state, and for each state its edges.
        self.dfa_start: dict[str, frozenset] = {}
        self.dfa_edges: dict[frozenset, dict[str, frozenset]] = {}
        self.stats = {"decisions": 0, "ll_fallbacks": 0, "max_lookahead": 0}

    # Configurations of SLL prediction are (alt, stack); of LL prediction
    # (alt, stack, d), where the entries below[d - 1], ..., below[0] of the parser
    # stack (pairs whose first part is the symbol) follow the stack. The parser
    # stack is read during a prediction, never copied or changed.

    def _closure_sll(self, configs) -> frozenset:
        out, todo = set(), list(configs)
        while todo:  # @line L26
            c = todo.pop()
            if c in out:
                continue
            out.add(c)
            alt, st = c
            if st and st[0] in self.nts:
                for q in self.alts[st[0]]:  # @line L28
                    todo.append((alt, self.productions[q][1] + st[1:]))
        return frozenset(c for c in out if not _starts_nt(c[1], self.nts))  # @line L29

    def _closure_ll(self, configs, below: list) -> frozenset:
        out, todo = set(), list(configs)
        while todo:
            alt, st, d = todo.pop()
            while not st and d > 0:  # @line L27
                st, d = (below[d - 1][0],), d - 1
            c = (alt, st, d)
            if c in out:
                continue
            out.add(c)
            if st and st[0] in self.nts:
                for q in self.alts[st[0]]:
                    todo.append((alt, self.productions[q][1] + st[1:], d))
        return frozenset(c for c in out if not _starts_nt(c[1], self.nts))

    def _predict(self, a: str, tokens: list[str], i: int, below: list) -> int:
        """The production to use for nonterminal a at token i (SLL with LL fallback)."""
        n = len(tokens)
        s = self.dfa_start.get(a)
        if s is None:
            s = self._closure_sll((q, self.productions[q][1]) for q in self.alts[a])  # @line L12
            self.dfa_start[a] = s
        t = i
        while True:  # @line L13
            self.stats["max_lookahead"] = max(self.stats["max_lookahead"], t - i)
            alts = {c[0] for c in s}
            if len(alts) == 1:  # @line L14
                return alts.pop()
            if any(not st for _, st in s):  # @line L15
                self.stats["ll_fallbacks"] += 1
                return self._predict_ll(a, tokens, i, below)
            by_alt = {q: frozenset(st for p, st in s if p == q) for q in alts}
            if len(set(by_alt.values())) == 1:  # @line L16
                return min(alts)
            if t == n:  # @line L17
                raise ParseError(n, f"input ends inside {a}")
            edges = self.dfa_edges.setdefault(s, {})
            nxt = edges.get(tokens[t])
            if nxt is None:  # @line L18
                nxt = self._closure_sll((p, st[1:]) for p, st in s if st[0] == tokens[t])
                edges[tokens[t]] = nxt
            if not nxt:  # @line L19
                raise ParseError(t, f"no alternative of {a} fits")
            s, t = nxt, t + 1

    def _predict_ll(self, a: str, tokens: list[str], i: int, below: list) -> int:
        n = len(tokens)
        c = self._closure_ll(((q, self.productions[q][1], len(below)) for q in self.alts[a]), below)  # @line L20
        t = i
        while True:  # @line L21
            self.stats["max_lookahead"] = max(self.stats["max_lookahead"], t - i)
            alts = {x[0] for x in c}
            if len(alts) == 1:  # @line L22
                return alts.pop()
            by_alt = {q: frozenset((st, d) for p, st, d in c if p == q) for q in alts}
            if len(set(by_alt.values())) == 1:  # @line L23
                return min(alts)
            if t == n:  # @line L24
                done = [p for p, st, d in c if not st and d == 0]
                if not done:
                    raise ParseError(n, f"input ends inside {a}")
                return min(done)
            c = self._closure_ll(((p, st[1:], d) for p, st, d in c if st and st[0] == tokens[t]), below)  # @line L25
            if not c:
                raise ParseError(t, f"no alternative of {a} fits")
            t += 1

    def parse(self, tokens: list[str]) -> Tree:
        tokens = list(tokens)
        n = len(tokens)
        holder: list = []
        stack: list[tuple[str, list]] = [(self.start, holder)]  # @line L1
        i = 0
        while stack:  # @line L2
            x, out = stack.pop()  # @line L3
            if x not in self.nts:  # @line L4
                if i < n and tokens[i] == x:  # @line L5
                    out.append(x)
                    i += 1
                    continue
                raise ParseError(i, f"expected {x}")  # @line L6
            alts = self.alts[x]  # @line L7
            if len(alts) == 1:
                p = alts[0]
            elif not alts:
                raise ParseError(i, f"{x} derives nothing")
            else:
                self.stats["decisions"] += 1
                p = self._predict(x, tokens, i, stack)  # @line L8
            node: tuple[str, list] = (x, [])
            out.append(node)
            rhs = self.productions[p][1]
            for y in reversed(rhs):  # @line L9
                stack.append((y, node[1]))
        if i < n:  # @line L10
            raise ParseError(i, "input left over")
        return holder[0]  # @line L11


def ll_star_parse(productions: list[Production], start: str, tokens: list[str]) -> Tree:
    """Parse tokens with a fresh parser for the grammar; see AllStarParser."""
    return AllStarParser(productions, start).parse(tokens)
test_ll_star_parsing.py 272 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ll-star-parsing/. The authors are listed in prov/DGJMDA.jsonl.

import itertools
import random

import pytest

from ll_star_parsing import AllStarParser, ParseError, ll_star_parse


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 left_recursive_oracle(prods):
    """Brute force: rewrite only the first symbol of sentential forms, from each A."""
    nts = {a for a, _ in prods}
    nullable = {a for a, ws in lang_upto(prods, 0).items() if () in ws}
    for a in nts:
        seen = set()
        todo = [rhs for x, rhs in prods if x == a]
        while todo:
            f = todo.pop()
            if f in seen or len(f) > 12:
                continue
            seen.add(f)
            if not f or f[0] not in nts:
                continue
            if f[0] == a:
                return True
            for x, rhs in prods:
                if x == f[0]:
                    todo.append(rhs + f[1:])
            if f[0] in nullable:
                todo.append(f[1:])
    return False


def expected(prods, start, w):
    """("ok",) if w is in the language, else ("error", k) with k the longest viable prefix."""
    good = productive_only(prods)
    m = len(w)
    if tuple(w) in lang_upto(good, m).get(start, set()):
        return ("ok",)
    pre = prefix_lang(good, m).get(start, set())
    k = 0
    while k < m and tuple(w[: k + 1]) in pre:
        k += 1
    return ("error", k)


def run(parser, w):
    try:
        t = parser.parse(w)
    except ParseError as e:
        return ("error", e.position), None
    return ("ok",), t


# The example of the page: the choice at S needs to read past every a.
EX = [("S", ("A", "c")), ("S", ("A", "d")), ("A", ("a", "A")), ("A", ("b",))]


def test_example_of_the_page():
    p = AllStarParser(EX, "S")
    t = p.parse(list("aabd"))
    assert t == ("S", [("A", ["a", ("A", ["a", ("A", ["b"])])]), "d"])
    assert p.stats == {"decisions": 4, "ll_fallbacks": 0, "max_lookahead": 4}
    s0 = p.dfa_start["S"]
    assert s0 == frozenset({(0, ("a", "A", "c")), (0, ("b", "c")),
                            (1, ("a", "A", "d")), (1, ("b", "d"))})
    s1 = p.dfa_edges[s0]["b"]
    assert p.dfa_edges[s0]["a"] == s0
    assert s1 == frozenset({(0, ("c",)), (1, ("d",))})
    assert p.dfa_edges[s1]["d"] == frozenset({(1, ())})
    for w in ["abc", "bd", "aaaaaaabc"]:
        check_tree(EX, "S", p.parse(list(w)), list(w))
    with pytest.raises(ParseError) as err:
        p.parse(list("aab"))
    assert err.value.position == 3
    with pytest.raises(ParseError) as err:
        p.parse(list("aaba"))
    assert err.value.position == 3


def test_long_lookahead_is_not_ll_k():
    p = AllStarParser(EX, "S")
    for k in range(1, 60):
        w = ["a"] * k + ["b", "d" if k % 2 else "c"]
        check_tree(EX, "S", p.parse(w), w)
    assert p.stats["max_lookahead"] == 61
    assert len(p.dfa_edges) <= 6


def test_full_context_fallback():
    g = [("S", ("x", "A", "a")), ("S", ("y", "A", "b")), ("A", ("a",)), ("A", ())]
    p = AllStarParser(g, "S")
    for w, ok in [("xa", True), ("xaa", True), ("ya", False), ("yb", True), ("yab", True),
                  ("xab", False), ("x", False)]:
        got, t = run(p, list(w))
        assert got == expected(g, "S", list(w)), w
        assert (got == ("ok",)) == ok
        if ok:
            check_tree(g, "S", t, list(w))
    assert p.stats["ll_fallbacks"] >= 2


def test_ambiguous_grammars():
    g = [("S", ("i", "S")), ("S", ("i", "S", "e", "S")), ("S", ("x",))]
    t = ll_star_parse(g, "S", list("iixex"))
    check_tree(g, "S", t, list("iixex"))
    g = [("S", ("A",)), ("S", ("B",)), ("A", ("a",)), ("B", ("a",))]
    assert ll_star_parse(g, "S", ["a"]) == ("S", [("A", ["a"])])
    g = [("S", ("a", "A")), ("S", ("a", "B")), ("A", ()), ("A", ("b",)), ("B", ()), ("B", ("c",))]
    assert ll_star_parse(g, "S", ["a"]) == ("S", ["a", ("A", [])])
    assert ll_star_parse(g, "S", ["a", "c"]) == ("S", ["a", ("B", ["c"])])
    g = [("S", ("S", "S")), ("S", ("a",))]
    with pytest.raises(ValueError):
        AllStarParser(g, "S")


def test_left_recursion_is_rejected():
    for g in ([("E", ("E", "+", "x")), ("E", ("x",))],
              [("S", ("A", "S", "b")), ("S", ("c",)), ("A", ())],
              [("S", ("A", "a")), ("A", ("B",)), ("B", ("S",)), ("B", ("b",))]):
        with pytest.raises(ValueError):
            AllStarParser(g, g[0][0])


def test_empty_input_and_empty_language():
    g = [("S", ("a", "S")), ("S", ())]
    assert ll_star_parse(g, "S", []) == ("S", [])
    g = [("S", ("a", "S"))]
    with pytest.raises(ParseError) as err:
        ll_star_parse(g, "S", [])
    assert err.value.position == 0


def test_deep_input_without_recursion():
    n = 100000
    g = [("S", ("a", "S", "b")), ("S", ())]
    p = AllStarParser(g, "S")
    t = p.parse(["a"] * n + ["b"] * n)
    depth, x = 0, t
    while x[1]:
        depth += 1
        x = x[1][1]
    assert depth == n
    with pytest.raises(ParseError) as err:
        p.parse(["a"] * n + ["b"] * (n - 1))
    assert err.value.position == 2 * n - 1


def test_random_against_brute_force():
    rng = random.Random(20111014)
    checked = accepted = rejected_grammars = 0
    for _ in range(1000):
        g = random_grammar(rng, rng.randint(1, 4), ["a", "b"], eps_prob=0.25)
        if rng.random() < 0.3:
            g.append((rng.choice(["S", "A"]), (rng.choice(["S", "A"]),)))
        if left_recursive_oracle(productive_only(g)):
            with pytest.raises(ValueError):
                AllStarParser(g, "S")
            rejected_grammars += 1
            continue
        p = AllStarParser(g, "S")
        words = [list(w) for m in range(6) for w in itertools.product("ab", repeat=m)]
        for w in words:
            want = expected(g, "S", w)
            got, t = run(p, w)
            assert got == want, (g, w)
            if t is not None:
                check_tree(g, "S", t, w)
                accepted += 1
            checked += 1
        for w in rng.sample(words, 8):
            assert run(AllStarParser(g, "S"), w)[0] == expected(g, "S", w)
    assert checked >= 20000 and accepted >= 600 and rejected_grammars >= 100

What the next level needs

  • the tests did not run in this build

Read the full page about LL(*) parsing