astero¶
A compiler writes down the same structural facts many times, and they drift.
Which fields of an assignment bind a name. Which positions a renamer must reach. Which productions your code generator handles. Which operand slots a pass may rewrite. Each is knowledge your language's grammar already determines, and each is typically a hand-written table sitting next to the thing it mirrors, free to fall out of step.
astero declares the grammar once and derives the rest.
What it looks like¶
Ask the grammar for the table.
from astero.lang_py import PY, VARS
PY.ident_slots(VARS)["arg"] # ('arg',) — every slot a renamer must reach
PY.definitions(VARS)["For"] # ('target',) — positions that introduce a name
PY.definitions(VARS)["FunctionDef"] # ('name',)
Fifteen productions hold a variable name. A hand-written renamer usually lists four or five and misses vararg, kwarg, lambda parameters and async def parameters.
Write a rewrite that cannot set the wrong context.
import ast
from astero import Pass, rules
from astero.lang_py import PY
DESUGAR = Pass(
"desugar",
rules("ast.AugAssign(_t, _o, _v) => ast.Assign([_t], ast.BinOp(_t, _o, _v))"),
eliminates=(ast.AugAssign,),
grammar=PY,
)
ast.unparse(DESUGAR(ast.parse("b[1] += 5"))) # 'b[1] = b[1] + 5'
The rule never mentions ctx. astero recomputes it from the field's role, so the subscripted target and the plain one go through the same rule. A real compiler shipped NaN for that exact input because its hand-written version set Store on the subscript.
Move code without capturing a name.
from astero.hygiene import Fresh, substitute
body = ast.parse("[n + k for k in xs]", mode="eval").body
substitute(body, {"n": ast.parse("k", mode="eval").body}, PY, VARS, fresh=Fresh())
# [k + _t1 for _t1 in xs]
Substituting k for n would have been captured by the comprehension's own k. substitute replaces only uses, and given a supply of fresh names it renames the binder. An inliner without this turned [11, 21] into [20, 40].
Declare emission as a table of spellings and guards.
from astero.emit_rules import All, Both, Emitter, OpIs
js = Emitter(PY, typer=type_of)
js.rule("BinOp", "{left} + {right}", All((OpIs((ast.Add,)), Both((Int,)))))
js.rule("BinOp", "add({left}, {right})", OpIs((ast.Add,)))
js.to_text(ast.parse("1 + 2", mode="eval").body) # '1 + 2'
js.to_text(ast.parse("a + b", mode="eval").body) # 'add(a, b)'
First matching rule wins, so the guarded case sits above the general one. With a precedence table, brackets are derived; with levels=None and the operator last, template composition emits post-order, which is how the same notation reaches a stack machine.
Fail the build when a production has no handler.
from astero.coverage import dispatch
cover = dispatch(PY, (gen_expr.registry, gen_stmt.registry),
bases=("stmt", "expr"),
accounted={"desugared": {"AugAssign", "Assert"}})
assert not cover.missing, cover.explain()
Its first run on a real compiler found dead code that lowered assert a second and wrong way.
Why it exists¶
Every defect this design came from had one shape: structural knowledge written by hand and allowed to drift.
- A rewrite pass set
ctx=Store()by hand, andb[1] += 5compiled toNaN. - A renamer listed the slots it knew about (four of the nine that exist), so it renamed references to parameters it left alone.
- An inliner substituted at every
Namenode, so inliningf(99)into a body containing[x for x in ...]produced[99 for 99 in ...], which is not a program. - A back end looked up a builtin's spelling before asking the scope, so
map = 3emitted the source text of a runtime helper.
None of those was found by reading the code. Each was found by deriving the answer and comparing.
What it is, and is not¶
astero is a library of derivations over a declared grammar. You import it and ask it questions.
astero is not a parser generator, a code generator, or a framework. It does not own your pipeline, your IR, or your main. There is no astero build.
That distinguishes it from the toolkit tradition it comes out of: Cocktail, Eli, and their descendants generate a compiler's parts from declarations. astero answers questions about a declaration at run time, which is a smaller and more incremental claim: you can adopt one query in one function this afternoon.
What a grammar is here¶
Productions of fields, where each field carries a shape, a sort, and a role. Roles are what ASDL does not have and what every derivation needs:
child structural containment, drives traversal
attr plain data, never traversed
def(ns) introduces a name in namespace `ns`
use(ns) refers to a name in `ns`
defuse(ns) both, for read-modify-write positions
del(ns) removes a name
declare(ns) `global` and `nonlocal`
Two decisions make roles work, and both are in Roles: a role belongs to the parent's field rather than the child node, and namespaces separate positions that look alike.
Checked against CPython¶
Every derivation has an oracle, and the corpus is the standard library.
| derived | checked against | result |
|---|---|---|
ctx |
the parser | every position in the standard library, no disagreements |
| scopes and their names | symtable |
99.98% of about 78,000 blocks |
| emission | reparsing | 1,797 modules, 2,266,043 expressions |
| operand positions | postpile's own declaration | exact |
A derivation without an oracle is a claim.
Start here¶
- Getting started: install it and get one real answer out of it.
- Tutorial: a whole compiler for a Python subset, in about 170 lines.
- Adopting astero: putting it into a compiler you already have.