Skip to content

astero.emit_rules

A code generator written as guarded templates over the grammar.

astero.emit_rules

Emission as a declaration: one rule per production, guarded.

All three compilers in the corpus emit a language, not machine code, so there is no register allocation, no scheduling and no cost model. What is left is producing text with the children in the right places, bracketed and laid out, and that is written today as match, isinstance and f-strings, once per target.

A rule is a production name, a template, and an optional guard:

JS.rule("BinOp", "{left} {op} {right}", when=Both(NUMERIC))
JS.rule("BinOp", "_pyfunc_op_{op:name}({left}, {right})")

Rules are tried in order and the first whose guard holds wins, as in astero.python.rewriting. A rule with no guard always holds, so it goes last and gives the production an answer; a production with no answer at all is a hole the coverage check reports rather than a NotImplementedError in someone's program.

{field} emits that child and brackets it if the precedence table says so, which is the part the grammar and astero.emit already know. {field:x} is a projection when x is a declared projection name and a separator otherwise, so {args:, } joins a sequence and {result:type} runs the target's type spelling. {field?} omits an absent optional field.

A template cannot call, branch or hold state. Everything conditional is a guard from the closed vocabulary below, and everything computed is a projection declared once by name. That restriction is the same one Present/Absent puts on the grammar, and Parr's on StringTemplate: a template that can compute stops being a specification of output.

Is dataclass

The inferred type of field is one of types.

Both dataclass

Both operands of a binary production have one of types.

OpIs dataclass

The production's operator field is one of ops.

Const dataclass

field is a literal, optionally of a given Python type.

Has dataclass

The field is present and not empty.

The same question Present asks in the grammar, and for the same reason: yield with a value and yield without are two spellings, not one spelling with an optional hole, because the second has no trailing space.

All dataclass

Every guard holds. Any is the other connective.

Either dataclass

At least one guard holds.

Not dataclass

The guard does not hold.

A connective rather than a new predicate, which matters: the atoms stay four. can_use_strict_equality is expressible without it, as a disjunction of two Boths, and unreadably so.

EmitError

Bases: Exception

No rule applied, or a template named something the grammar lacks.

Emitter dataclass

A target's emission rules, over one grammar.

declared_level

declared_level(node: Any) -> Level | None

The binding power the table gives node, or None if it gives none.

Undeclared is not a level, and the two questions below answer it differently, so the lookup is separate from both.

level_of

level_of(node: Any) -> Level

How tightly node binds as emitted.

Which rule fires decides this, not the node alone: a + b spelled a + b binds at the + level, and spelled _pyfunc_op_add(a, b) it is an atom. Reading the node alone bracketed the call.

Undeclared means ATOM here: a production the table omits is emitted as a primary, so nothing brackets it.

inner_level

inner_level(node: Any) -> Level

How tightly the operator inside a template binds.

Not the same question as level_of, and undeclared means the opposite thing: a production the table omits imposes nothing on its holes. A statement is the case that matters, and it is most of a target. t = 0 came out t = (0) and return n * 2 came out return (n * 2), because Assign and Return are in no precedence table, and the missing entry was read as ATOM, the tightest context there is, rather than as no context at all.

// emitted as Math.floor({left}/{right}) is why this is not simply level_of: the result is an atom and the holes sit either side of a /, so bracketing them against the atom gave Math.floor((hi - lo)/(2)).

emit

emit(
    node: Any,
    outer: Level | None = None,
    *,
    right: bool = False,
) -> Doc

The text for node, bracketed for the context it sits in.

source_map

source_map(
    node: Any,
) -> list[tuple[int, int, tuple[int, int]]]

(start, end, (line, column)) for every emitted node with a position.

start and end index to_text(node), and the pair is where in the source that text came from. A source map is this list in whatever encoding the target wants.

Every rule's output carries the position of the node it emitted, so this needs no cooperation from the rules and no extra hole. A node the host built rather than parsed has no position and contributes nothing, which is why the list is usually shorter than the tree.

label

label(node: Any, name: str) -> str

A label unique to node, stable for the same name.

    A statement that branches needs somewhere to jump to, and the same
    label appears twice: once where it is defined and once where it is
    named. `{&done}` gives both the same text, and a second `While` in the
    same function gets a different one.

    This is what a flat target was missing. Statement *structure* already
    worked — a body hole renders the list, and a newline separator joins
    it — so `while` on a stack machine is a template like any other:

        "{&top}:

{test} JZ {&done} {body: } JMP {&top} {&done}:"

to_lines

to_lines(node: Any) -> list[str]

The emitted lines, split where the templates said to.

to_text(node).splitlines() is the obvious thing and it is wrong as soon as a value contains a newline: a string literal splits into two instructions. This walks the document, so only a break a template wrote is a break.