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

03 - Desugaring

Desugaring rewrites a few surface conveniences into the core forms the type checker and lowerer already understand, so those later stages need no special cases for the sugar. In Pyfun the pass lives in src/desugar.rs and covers three things: user-defined computation-expression builders, operator sections, and function composition. The design rule is stated in DESIGN.md §8.1: rewrite to ordinary calls and lambdas, then let normal inference and lowering take over.

Operator sections and composition

The smallest cases are the clearest. An operator wrapped in parentheses, like (+), is a first-class curried function. Rather than teaching the checker and emitter about operator values, op_func rewrites it to the lambda you would have written by hand:

#![allow(unused)]
fn main() {
// src/desugar.rs
pub fn op_func(op: BinOp, span: Span) -> Expr {
    let body = mk(
        ExprKind::Binary { op, lhs: Box::new(var("a", span)), rhs: Box::new(var("b", span)) },
        span,
    );
    mk(ExprKind::Fn { params: vec![/* a, b */], body: Box::new(body) }, span)
}
}

So (+) becomes fun a b -> a + b, and its num constraint, currying, and partial application all fall out of the ordinary rules with no bespoke code. Composition is the same tactic: f >> g desugars to fun x -> g (f x) and f << g to fun x -> f (g x). Composition has one extra care that a section does not, because its body embeds the operands, which may reference outer variables, so compose picks a parameter name (_pf_x, else _pf_x0, …) that is free of both operands’ free variables, ruling out capture. The pretty-printer keeps the faithful (op) / (f >> g) spelling regardless, so the roundtrip tests still see the source form.

The running example uses an explicit lambda in its fold, shapes |> List.fold (fun acc s -> acc + area s) 0.0, so it does not exercise a section. But the point-free cousin List.fold (+) 0.0 would desugar to exactly the lambda the example spells out, which is why both styles type-check and lower identically.

Computation-expression builders

Computation expressions are the F# flagship for monadic sugar, written builder { ... }. Pyfun splits them in two. The three built-ins keep bespoke native lowerings and are not desugared here, because a generic bind/return rewrite cannot produce idiomatic output for them. User-defined builders, on the other hand, are handled entirely by this pass. A builder is an in-file module providing protocol functions, and Builder { ... } desugars to calls on them. The protocol, from the module documentation:

#![allow(unused)]
fn main() {
// src/desugar.rs
//! | item            | desugaring                                           |
//! |-----------------|------------------------------------------------------|
//! | `let! x = e` …  | `B.bind e (fun x -> …)`                              |
//! | `do! e` …       | `B.bind e (fun _ -> …)`   (trailing `do! e` → `e`)   |
//! | `let x = e` …   | `(fun x -> …) e`                                     |
//! | `return e`      | `B.return_ e`        (must be last)                  |
//! | `return! e`     | `B.returnFrom e`     (must be last)                  |
//! | `yield e` …     | `B.combine (B.yield_ e) (B.delay (fun _ -> …))`      |
}

The driver, desugar_ce, is a recursion over the CE items. Each item is rewritten into a call on the builder module, threading the rest of the block through as a continuation lambda:

#![allow(unused)]
fn main() {
// src/desugar.rs
CeItem::LetBang { name, name_span, value } => {
    let cont = require_rest(builder, rest, span, "`let!`", value)?;
    Ok(call2(builder, "bind", value.clone(),
             lam(name.clone(), *name_span, cont, span), span))
}
}

Because the result is ordinary calls, the desugaring is type-directed for free: the builder’s own bind and return_ signatures determine the types through normal inference on the rewritten calls. There are no per-builder type rules and no per-builder codegen. Note too that the let!-bound name keeps its original span on the generated lambda parameter, so hover and rename still work on a name that only exists after desugaring.

The built-in result {}, natively

The three built-ins lower directly rather than through this pass, so it is worth seeing what “native” buys. Here is a small result {} block compiled with pyfun compile:

def safeDiv(a, b):
    def _pf_fn0():
        match Error("div by zero") if b == 0 else Ok(a):
            case Ok(x):
                return Ok(x / b)
            case Error(_pf_t0):
                return Error(_pf_t0)
    return _pf_fn0()

That is railway-oriented short-circuiting written as a real match: on Ok it continues, on Error it returns the error unchanged. A generic bind/return desugaring would produce a chain of closure calls instead, which is why the built-ins keep their own lowering while user builders take the desugaring path. The two mechanisms sit side by side: the same protocol shape, but the built-ins earn idiomatic Python by not being desugared.

Where you would add desugared sugar

A new value-level sugar that can be expressed with existing core forms belongs here. Add a constructor function to src/desugar.rs that builds the equivalent Fn/App/Binary tree (following op_func and compose), call it from the parser where the surface form is recognized, and keep the pretty-printer rendering the surface spelling so the roundtrip holds. If the sugar needs its own lowering to stay idiomatic, as the built-in CEs do, it instead belongs downstream in lowering, not in this pass.