Left-corner parsing in Python
Hybrid parser that predicts from the leftmost symbol of each production.
- File
left_corner_parsing.py, 115 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/left-corner-parsing/. The authors are listed in prov/P8PFGP.jsonl.
"""Generalized left-corner recognizer 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, cycles, left recursion and
ambiguity are all allowed.
A left-corner parser finds the first symbol of each production bottom-up, from
the input, and the other symbols top-down, as goals. This recognizer runs all
choices of such a parser at once with a chart. An item [C, i, B -> alpha . beta]
in E[k] says: while looking for the goal C from position i, the parser has
found alpha from i to k, and B is a left corner of C. DONE holds the triples
(C, i, k) such that the goal C was found from i to k.
"""
from typing import Sequence
Production = tuple[str, tuple[str, ...]]
Item = tuple[str, int, int, int] # (goal C, start i, production index, dot)
def left_corner_closure(productions: Sequence[Production], start: str) -> dict[str, set[str]]:
"""L[C]: the nonterminals B with C = B or C -> B ... -> ... B ... (first symbols)."""
nts = {a for a, _ in productions} | {start}
first: dict[str, set[str]] = {a: set() for a in nts}
for a, rhs in productions:
if rhs and rhs[0] in nts:
first[a].add(rhs[0])
closure: dict[str, set[str]] = {}
for c in nts:
seen = {c}
todo = [c]
while todo:
b = todo.pop()
for d in first[b]:
if d not in seen:
seen.add(d)
todo.append(d)
closure[c] = seen
return closure
def left_corner_chart(productions: Sequence[Production], start: str, w: Sequence[str]
) -> tuple[list[list[Item]], dict[tuple[str, int], set[int]]]:
"""Return the chart E[0..n] and DONE as a map (C, i) -> set of ends k."""
prods = [(a, tuple(r)) for a, r in productions]
nts = {a for a, _ in prods} | {start}
n = len(w)
lc = left_corner_closure(prods, start) # @line L1
by_first: dict[str, list[int]] = {} # productions D -> X gamma, by X
empty: list[int] = [] # productions D -> epsilon
for p, (a, rhs) in enumerate(prods):
if rhs:
by_first.setdefault(rhs[0], []).append(p)
else:
empty.append(p)
chart: list[list[Item]] = [[] for _ in range(n + 1)]
seen: list[set[Item]] = [set() for _ in range(n + 1)]
done: dict[tuple[str, int], set[int]] = {}
found: list[set[tuple[str, int, str]]] = [set() for _ in range(n + 1)]
waiting: dict[tuple[str, int], list[Item]] = {}
def add(item: Item, k: int) -> None:
if item not in seen[k]:
seen[k].add(item)
chart[k].append(item)
def start_goal(c: str, k: int) -> None:
done[(c, k)] = set()
waiting.setdefault((c, k), [])
if k < n: # @line L14
for p in by_first.get(w[k], []):
if w[k] not in nts and prods[p][0] in lc[c]:
add((c, k, p, 1), k + 1)
for p in empty: # @line L15
if prods[p][0] in lc[c]:
add((c, k, p, 0), k)
start_goal(start, 0) # @line L2
for k in range(n + 1): # @line L3
idx = 0
while idx < len(chart[k]): # @line L4
c, i, p, d = chart[k][idx]
idx += 1
b, rhs = prods[p]
if d == len(rhs): # @line L5
if (c, i, b) in found[k]: # B was already found from i to k under C
continue
found[k].add((c, i, b))
if b == c and k not in done[(c, i)]: # @line L6
done[(c, i)].add(k)
for (c2, i2, p2, d2) in waiting[(c, i)]:
add((c2, i2, p2, d2 + 1), k)
for q in by_first.get(b, []): # @line L7
if prods[q][0] in lc[c]:
add((c, i, q, 1), k)
elif rhs[d] in nts: # @line L8
y = rhs[d]
if (y, k) not in done: # @line L10
start_goal(y, k)
waiting[(y, k)].append((c, i, p, d)) # @line L9
for k2 in list(done[(y, k)]): # @line L11
add((c, i, p, d + 1), k2)
elif k < n and rhs[d] == w[k]: # @line L12
add((c, i, p, d + 1), k + 1)
return chart, done
def left_corner_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
"""Return True if w is in the language of the grammar."""
_, done = left_corner_chart(productions, start, w)
return len(w) in done[(start, 0)] # @line L13
test_left_corner_parsing.py 107 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/left-corner-parsing/. The authors are listed in prov/P8PFGP.jsonl.
import itertools
import random
from left_corner_parsing import left_corner_chart, left_corner_closure, left_corner_recognize
EXAMPLE = [("E", ("E", "+", "T")), ("E", ("T",)), ("T", ("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 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: # 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_chart():
w = ["a", "+", "a"]
chart, done = left_corner_chart(EXAMPLE, "E", w)
show = [sorted((c, i, p, d) for c, i, p, d in col) for col in chart]
assert show[0] == []
assert show[1] == [("E", 0, 0, 1), ("E", 0, 1, 1), ("E", 0, 2, 1)]
assert show[2] == [("E", 0, 0, 2)]
assert show[3] == [("E", 0, 0, 1), ("E", 0, 0, 3), ("T", 2, 2, 1)]
assert done == {("E", 0): {1, 3}, ("T", 2): {3}}
assert left_corner_closure(EXAMPLE, "E") == {"E": {"E", "T"}, "T": {"T"}}
assert left_corner_recognize(EXAMPLE, "E", w)
assert not left_corner_recognize(EXAMPLE, "E", ["a", "+"])
def test_empty_input_and_epsilon():
g = [("S", ()), ("S", ("A", "S")), ("A", ())]
assert left_corner_recognize(g, "S", [])
assert not left_corner_recognize(g, "S", ["a"])
g2 = [("S", ("A", "A", "a")), ("A", ())]
assert left_corner_recognize(g2, "S", ["a"])
assert not left_corner_recognize(g2, "S", [])
def test_cycles_units_and_no_productions():
g = [("S", ("S",)), ("S", ("A",)), ("A", ("S",)), ("A", ("a",)), ("S", ("S", "S"))]
for k in range(5):
assert left_corner_recognize(g, "S", ["a"] * k) == (k >= 1)
assert not left_corner_recognize([("A", ("a",))], "S", ["a"])
assert not left_corner_recognize([], "S", [])
def test_random_against_oracle():
rng = random.Random(20261106)
inputs = 0
for _ in range(300):
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:
assert left_corner_recognize(g, "S", w) == derives(g, "S", w), (g, w)
inputs += 1
assert inputs >= 500
def test_long_inputs_no_recursion_limit():
right = [("S", ("a", "S")), ("S", ("b",))]
left = [("S", ("S", "a")), ("S", ("b",))]
n = 3000
assert left_corner_recognize(right, "S", ["a"] * n + ["b"])
assert not left_corner_recognize(right, "S", ["a"] * n)
assert left_corner_recognize(left, "S", ["b"] + ["a"] * n)
What the next level needs
- the tests did not run in this build