Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

08 - Emission

Lowering produces a Python-AST IR; emission turns it into text. The IR and the emitter both live in src/python_emitter/mod.rs, and the design goal (per DESIGN.md §5) is blunt: the output is the interface. Pyfun compiles to Python you are meant to read, keep in your repo, and hand to Python tooling, so the emitted source has to look like something a person would write.

A small Python IR

PyModule is a flat list of PyStmt, and the statement enum covers only what lowering produces: Import, Assign, Return, Expr, FuncDef, If, Match, Try, ClassDef, and a handful more. If you are new to Rust, this is a textbook use of an enum: each statement shape is one variant carrying exactly its own fields, and the emitter is one match over them. The IR grows only when the language does, so there is no dead abstraction to wade through.

#![allow(unused)]
fn main() {
// src/python_emitter/mod.rs
pub enum PyStmt {
    Import(String),
    Assign { target: String, value: PyExpr },
    Return(PyExpr),
    Match { subject: PyExpr, cases: Vec<PyCase> },
    // ...
}
}

The emitter is line-based and deterministic

emit walks the module, emits the imports that dataclasses need once at the top, then recurses through emit_block/emit_stmt with a depth counter. Indentation is four spaces per level via a single line helper. There is no formatter pass and no randomness: the same IR always produces byte-identical text. That determinism is load-bearing beyond tidiness. The REPL and Jupyter kernel (see tooling) split the emitted program into top-level chunks and send only the chunks the worker has not run yet, which is only sound because re-emitting an unchanged definition yields the exact same bytes.

Match lowers one-for-one

A Pyfun match becomes a Python match, arm for arm. emit_stmt renders the subject, then each case with its optional guard:

#![allow(unused)]
fn main() {
// src/python_emitter/mod.rs
PyStmt::Match { subject, cases } => {
    line(out, depth, &format!("match {}:", expr(subject)));
    for case in cases {
        let guard = match &case.guard {
            Some(g) => format!(" if {}", expr(g)),
            None => String::new(),
        };
        line(out, depth + 1, &format!("case {}{guard}:", pattern(&case.pattern)));
        emit_block(&case.body, depth + 2, out);
    }
}
}

Constructor patterns emit as Python class patterns (case Circle(r):), which is why Pyfun’s ADTs lower to @dataclass classes with generated __match_args__: it lets the emitted match read like idiomatic structural Python. The checker has already proven the match exhaustive, so the trailing case _: raise RuntimeError(...) is a belt-and-braces guard, not something the program should ever hit.

The running example, in full

Here is the complete emitted Python for the running example, straight from pyfun compile:

from dataclasses import dataclass
import functools
import math
def _pf_fold(f, acc, xs):
    return functools.reduce(f, xs, acc)
@dataclass(frozen=True, repr=False)
class Circle:
    _0: float
    def __repr__(self):
        return f"Circle({self._0!r})"
@dataclass(frozen=True, repr=False)
class Rect:
    _0: float
    _1: float
    def __repr__(self):
        return f"Rect({self._0!r}, {self._1!r})"
def area(s):
    match s:
        case Circle(r):
            return 3.14159 * r * r
        case Rect(w, h):
            return w * h
        case _:
            raise RuntimeError("non-exhaustive match")
shapes = [Circle(2.0), Rect(3.0, 4.0)]
total = _pf_fold(lambda acc, s: acc + area(s), 0.0, shapes)
side = math.sqrt(16.0)
print(f"total {total}, side {side}")

Walking through it: the header imports are emitted once because a class is present and helpers are needed. _pf_fold is the emitted-on-demand List.fold helper, a thin wrapper over functools.reduce so the curried Pyfun function is a single callable. Circle and Rect are frozen dataclasses: frozen=True matches Pyfun’s immutability, and the hand-written positional __repr__ (with repr=False suppressing the generated one) makes a value print the way it was constructed, Circle(2.0) rather than Circle(_0=2.0). area is the return-position match. The three top-level lets become plain assignments, the fold folder is an inline lambda, sqrt erased its unit down to math.sqrt(16.0), and the final print uses a real Python f-string. Running it prints total 24.56636, side 4.0. Nothing about this file signals that it came from a compiler.

Where you would add a new emitted construct

A new Python shape (say a while loop for a future optimization) starts as a variant on PyStmt or PyExpr in src/python_emitter/mod.rs, with its rendering arm in emit_stmt/emit_expr, and only then does lowering get to produce it. Adding the IR node first keeps the “no string splicing” contract intact and gives you one place to get indentation and precedence right.