Puffin

Puffin modules: SML structure discipline, Racket surface, separate compilation

This document describes Puffin's module system for anyone writing multi-file Puffin programs: how files become modules, how names and types cross module boundaries, optional signatures, and how the Racket toolchain compiles modules separately. Implementation internals โ€” the resolver, name mangling, the .pufi interface format, build caching โ€” live in the implementation notes at docs/internal/modules.md.

Design goals, in order: (1) modularity you can trust (explicit exports, no accidental capture between files); (2) genuine separate compilation (a module compiles to a .o + a small interface file; downstream compilation reads only the interface); (3) optional specs โ€” signatures constrain and document when you want them and stay out of the way when you don't.

Modules work on every route: the native backends, the bytecode VM, the browser playground's file tabs, and the interpreter all resolve the same way (puffincc-src/ itself is a module DAG). Separate compilation is a feature of the Racket toolchain; puffincc compiles module programs whole-program.

Surface language

A file is a module (Racket's discipline). Its name is its path.

;; geometry.puf
(provide area perimeter)            ; explicit export list (Racket)

(require "vec.puf")                 ; plain import: provided names in scope
(require "matrix.puf" #:as M)       ; qualified import: M.transpose, M.det

(define pi 314159)                  ; private unless provided
(define (area r) (* pi (* r r)))
(define (perimeter r) (* 2 (* pi r)))

REPL and single-file programs are unchanged: a file with no require/provide compiles exactly as before (the module pass is the identity on it).

Two consequences of how resolution renames worth knowing day to day:

Type names are first-class exports

A define-type binds its head AND its constructors as ordinary top-level names, and all of them provide/require/qualify/rename uniformly โ€” a type import looks exactly like a value import:

;; shapes.puf
(provide Shape Point Circle Rect area)   ; the TYPE provides too
(define-type Shape (Point) (Circle Int) (Rect Int Int))

;; main.puf
(require "shapes.puf")
(define (describe [s : Shape]) : Sym ...)     ; imported type, by name

;; or qualified โ€” M.Type in type positions, like M.name in expressions
(require "shapes.puf" #:as S)
(define (biggest [a : S.Shape] [b : S.Shape]) : S.Shape ...)

What you can rely on:

Separate compilation carries all of this across module boundaries: a dependency's exports typecheck at their recorded interface types, and imported ADTs (constructors, exhaustiveness, cast blame) behave exactly as under whole-program compilation.

Signatures (the SML mix-in, optional)

;; ring.pufs โ€” a signature file
(signature RING
  (val zero)                 ; a value
  (fun add 2)                ; a function of stated arity
  (fun mul 2)
  (fun neg 1))
;; int-ring.puf
(provide #:sig "ring.pufs")  ; ascribe: exports become exactly the sig
(define zero 0)
(define (add a b) (+ a b)) ...

Separate compilation

Separate compilation lives in the Racket toolchain, targeting arm64:

bin/puffin -c --module geometry.puf     # build one module (cached)
bin/puffin -c --separate main.puf -o prog
                                        # compile every DAG module
                                        # separately (cached), link

--module compiles one module to a cached .o (its names mangled so objects never collide) plus a .pufi interface file โ€” a small s-expression recording what the module provides, at what types, along with its dependencies' interface digests. Compiling a module that requires others reads ONLY their .pufi files (compiling them first if missing or stale โ€” a built-in make). --separate compiles every module in the DAG this way, links every .o plus the runtime, and the entry's prelude calls each module's init in postorder before its own top-level runs. The default no-flag route is untouched โ€” its output is byte-identical whether or not separate compilation is ever used.

Interfaces are typed: every provide row carries the export's gradual type (declared, derived, or synthesized โ€” an unannotated function still exports its arity shape, so cross-unit arity misuse is a compile-time error), and provided ADTs travel with their full definitions, so an importer's pattern matches, exhaustiveness warnings, and cast blame agree with the dependency byte-for-byte. Staleness is type-aware: a signature-level change (a type tightened, a constructor added) rebuilds dependents; a body-only edit rebuilds only the module itself.

The interface format, mangling scheme, per-module literal tables, and cache layout are specified in the implementation notes.

Limitations