User guide¶
Each module answers one question. This is the question, the call, and the failure it prevents.
grammar: the declaration and its queries¶
A Grammar is productions of fields; each field has a shape, a sort and a role.
Static queries answer which fields, over every node of a production:
PY.positions(Kind.DEF, VARS) # production -> field names that bind
PY.operands(VARS) # ... that read
PY.definitions(VARS) # ... that introduce
PY.ident_slots(VARS) # ... that hold a bare identifier
PY.children("FunctionDef") # fields a traversal descends into
PY.concrete("stmt") # productions that can actually appear
PY.with_trait("pure") # productions carrying a trait
Instance queries answer which values, for one node, applying conditions:
grammar.reads(node, ns) # the contents of every slot that reads a name
grammar.binds(node, ns) # ... that introduces one
The difference matters when a field's role depends on a sibling. postpile's AssignValue.target is a definition when declare is set and a use when it is not; a static table must report both, and only an instance can say which. Writing that walk inline is the sign you want the instance query.
Three front doors¶
lang_py.build(module=my_ast) # a module of ast-like classes
Grammar.from_dataclasses(name, classes, roles=…) # annotated dataclasses
GrammarBuilder() # by hand
from_dataclasses reads names, sorts and shapes off the annotations, so only the roles are authored. postpile declares twelve SSA instructions in twelve lines that way.
rewriting: rules over trees¶
- A rule is
pattern => result, or a decorated function for a fold. @rewrite("pattern")puts a callable rule next to its pattern.- A callable returning
Nonedeclines, and the next rule is tried. - A rule may return a list, which is spliced into a statement list.
stop_at=(ast.FunctionDef, ...)keeps a scope-local rewrite out of nested scopes.eliminates=is a postcondition;fix_contextsrecomputesctxfrom roles.
Prevents: a rewrite that sets the wrong context, and one that fires nowhere.
scopes: the scope tree and what each block binds¶
Two tables, deliberately distinct: SCOPES says what symtable calls a block, BINDING_SCOPES says what a name binds over. PEP 709 separated them at 3.12. Ask the one that matches your question.
Prevents: an enumeration of binding positions that misses for, with … as, except … as or the walrus.
hygiene: moving code without breaking names¶
substitute(node, mapping, grammar, ns, scopes=…, fresh=…)
rename(node, mapping, grammar, ns)
Fresh.avoiding(node, grammar)
free_names(node, grammar, ns)
substitute replaces only uses, refuses when the target rebinds a substituted name, and, given fresh, renames a binder that would capture.
Prevents: [99 for 99 in ...], and an argument's free name captured by a comprehension in the body.
coverage: gating a dispatch table against the language¶
cover = dispatch(PY, (gen_expr.registry, gen_stmt.registry),
bases=("stmt", "expr"), accounted={"desugared": {...}})
assert not cover.missing, cover.explain()
handlers() reads singledispatch registries; match_arms(fn) reads case Cls() patterns back out of source, so a match is measurable too. accounted names each deliberate omission, and absent/redundant fail when a reason goes stale.
Prevents: a production nobody handles, found by a user rather than a test. Its first run found dead code that lowered assert a second and wrong way.
emit_rules: emission as a declaration¶
js = Emitter(grammar, levels=LEVELS, typer=get_type, fallback=…)
js.projection("sym", lambda op: SYMBOL[type(op)])
js.rule("BinOp", "{left} + {right}", All((OpIs((ast.Add,)), Both(NUMERIC))))
Guards are closed on purpose: Is, Both, OpIs, Const, Has, joined by All, Either, Not. A guard that admits host code stops the rule set being readable as a table.
With levels, brackets are derived from binding powers. With levels=None and the operator last, template composition emits post-order, which is how the same notation reaches a stack machine and WebAssembly's flat form.
Where it stops: guards about a node's types convert; guards about an operand's provenance (is it provably a small constant? can this operation overflow?) are a cost model, and stay written.
emit: documents and derived brackets¶
Doc, Nest, Line, a renderer, and needs_parens from a precedence table. emit_py is a whole Python emitter built on it, checked by emit-then-reparse over the standard library.
tables: the tables a grammar cannot derive¶
Family.parse declares an operation-by-type matrix with the normalisation stated once and holes written as holes. Use it when one rule ("a Bool crosses as an Int64") would otherwise be spelled in every row.
generate: programs that exercise every declared position¶
missing_coverage turns "have I tested every role in the grammar?" into a checkable obligation.