How this Rust compiler is built: crate layout, the lowering passes, and the language server. It is
the map from a semantic rule to the code that implements it. DESIGN.md is the source of truth for
what the language does and why; this file is the source of truth for how this compiler does it.
Read them together for any compiler change: DESIGN for the rule, this file for where it lives. The
exact current code is always the final authority; when this file and the code disagree, fix one of
them.
Keep modules small and single-purpose — exhaustiveness, type+effect inference, and codegen each grow large and must not bleed together.
src/
lexer/ tokenizer, token types, lex errors, the offside rule
parser/ recursive-descent + precedence climbing; ast.rs = span-carrying AST
ast/ traversal + visitor utilities, pretty-printer
desugar.rs computation-expression desugaring (DESIGN §8.1): builder{} → bind/return/…
types/ HM inference + effect inference/checking, exhaustiveness, units (DESIGN §8.2)
lowering/ Pyfun AST → Python-AST IR; scope/name-binding analysis; unit erasure
fold_loop.rs hot folds become loops (DESIGN §5.1)
decode_spec.rs statically-known decoders deforest (DESIGN §5.3)
self_tail_call.rs a saturated self tail call becomes `while True` (DESIGN §5.4)
python_emitter/ Python-AST IR → readable source (py311.rs = the --target 3.11 rewrite)
diagnostics/ rustc-style errors: codes (E001…), levels, spans, notes
project/ module graph: acyclic import resolution, cross-module checking
manifest.rs pyfun.toml reader/editor, project root and vendored façades (DESIGN §6.2)
lsp/ front-end-first language server (stdio JSON-RPC)
json.rs hand-rolled, dependency-free JSON value + parser + serializer
resolve.rs AST resolver: symbol_at / find_references / definitions
repl.rs read-eval-print loop over one long-lived Python worker
kernel.rs the REPL session inverted, for the Jupyter kernel
packages.rs `pyfun add` / `install` / `remove`: façade packages (DESIGN §6.2)
native_build.rs `pyfun build --native`: compile, then mypyc (DESIGN §5.6)
main.rs the CLI: check/compile/run/build/add/install/remove/parse/lsp/repl/kernel-engine
editors/vscode/ minimal VS Code client that launches `pyfun lsp`
tests/ parser tests, compile tests, .pyfun fixtures (favor snapshot/golden tests)
The shared Python runtime support (the Option/Result classes, and the _pf_scope context
variable Task.scope publishes its TaskGroup through) is not a directory: it is generated by
lowering::runtime_module() into a _pyfun_rt.py beside the emitted output.
There are no dependencies — Cargo.toml's [dependencies] is empty, so the CLI is hand-rolled
argument parsing rather than clap.
Build order: lexer + parser + ast → desugar → types → lowering +
python_emitter → diagnostics + main → project → lsp.
Where each rule lives. Parse: parse_opaque_type (src/parser/mod.rs; opaque is a contextual
keyword — the item dispatcher matches Ident("opaque") + Tok::Type lookahead), producing
TypeDeclKind::Newtype(TypeExpr) (src/parser/ast.rs). Check: build_decls pass 2
(src/types/mod.rs) registers the constructor as a single-ctor sum sharing the type's name
(ctors/type_ctors), so inference, pattern checking, and Maranget exhaustiveness need no new
rules; collect_exported_types includes newtypes so they cross file modules. Lower: the
Lowerer.newtype_ctors set (src/lowering/mod.rs, seeded in Lowerer::new; project mode extends
it with imported qualified names via ImportContext.newtype_ctors ← project::export_newtypes).
Erasure sites: fully-applied wrap → the bare argument (early branch in application lowering,
Var and qualified Field heads); first-class reference → _pf_id (in lower_var and
lower_module_member); Pattern::Ctor → its payload pattern (lower_pattern); no class emitted.
Because an erased pattern can become irrefutable, seal_cases finalizes every lowered match:
truncate at the first unconditional lowered case, else append the defensive raise — Python
rejects any case after an irrefutable one as a SyntaxError, so this is judged on PyPattern, not
the source arm.
Lowering does not yet consume inferred types, so the arity that build_call uses to collapse a
full application, wrap a partial one in functools.partial, or apply an over-application's
remainder one argument at a time comes from syntax. Three sources, consulted in lower_application
in this order: the block-scoped local_arities table (a let pair a b = … or let pair = fun a b -> … in an enclosing block, issue #84), the module-level arities/ctor_arity tables (top-level
functions, imported members, data constructors), and a fun literal's own parameter count when it
is applied in place. A parameter has no entry anywhere and stays arity-unknown, so its application
is emitted n-ary as written.
local_arities is kept in step with the fold pass's local_fn_defs registry so an entry can only
mean "this name resolves to that local function here": both are snapshotted and restored per block
(save_local_scope/restore_local_scope, also around a computation expression, whose body is its
own Python function), both are displaced by parameters and match-arm binders for the binder's
extent (shadow_local_fns/unshadow_local_fns), and both are evicted when a non-function let or
a CE binder rebinds the name (note_block_let/bind_ce_target). One ordering detail: a function
let registers its arity in enter_block_let before its value is lowered, so a recursive local
function can partially apply itself, while a non-function let evicts after its value is
lowered, since let pair = pair 1 reads the previous binding.
The lowering strategy and its observable contracts are in DESIGN.md §5. The performance-directed
passes below are pure implementation: each is default-on, gated by an env-var kill switch for
differential testing, and proven value-identical to the unoptimized lowering by a differential gate
that byte-compares both variants' output on the network-rail example.
The immutable-style collections rebuild a fresh container on every step, so a fold that builds a
collection by repeated Map.add/List.concat/Set.add is quadratic (each step copies the whole
accumulator). The pass (src/lowering/fold_loop.rs, hooked in lower_application, defaulting on;
PYFUN_NO_FOLD_OPT is the kill switch) recognizes the common case and rewrites
functools.reduce(f, xs, acc) into a for-loop over a mutable accumulator, turning the
copy-returning ops into in-place mutations (Map.add→m[k]=v, List.concat→.append/.extend,
Set.add→.add), collapsing the build to linear. This adds two Python-IR nodes: PyStmt::For
(whose target is a PyForTarget: a name or a nested tuple of names) and PyStmt::SubscriptAssign.
Soundness is what the pass must protect. The rewrite is observable only through a retained reference to a
mutated container, so the pass is a set of conservative syntactic proof obligations on the AST,
checked with no side effects on the lowerer (a rejected fold falls through to the byte-identical
_pf_fold lowering). A fold qualifies only when: it is a fully-applied Seq.fold/List.fold
(exactly 3 args); the folder is a 2-ary lambda literal or a top-level named let (an inlinable
body, not a mut/extern/imported member/parameter); the accumulator parameter is a plain name (the
pass substitutes it by name), while the element parameter may be any irrefutable pattern, since it is
only ever the loop target: a name, a wildcard, or a tuple of those becomes Python's own
for (p, l) in steps: header (PyForTarget), and any other shape binds a temp and unpacks on the
first line of the body, so fun m (p, l) -> Map.add p l m stays linear like its fst/snd
spelling and the pattern's bound names count as body binders for the P8 collision check; the
accumulator is a fresh literal collection or
a flat tuple of them (a Var init is rejected — it may be read after the fold); every slot is
threaded position-preservingly (no swap, no duplication, no cross-slot storage, no closure
capture, no escape to a user function — retention is unknowable, so reject); reads of a slot use a
whitelist of ops that return scalars or fresh copies; and inlining is capture-safe (the folder's free
variables and introduced binders are disjoint from every enclosing Python frame, and at module
scope, where the loop's names are globals, from every top-level name the program binds other than
the binding being defined, tracked in cur_top_names; inside an in-file module only a lambda or
block-local folder inlines, since a bare name there may be a mangled sibling member). Read-before-mutate and effect order are preserved by lowering every op argument
(hoisting non-atomic ones to temps) before emitting the mutations. When in doubt, reject. The
folder is always pure (an effectful folder does not typecheck against fold's scheme), so the only
ordering hazard is a value dependency between slots, which the hoisting handles. Full preconditions
(P1–P11) live in the design memo the pass was built from.
Tier B extends the qualifying shapes, each behind the same differential gate:
block-local named folders (dedupLegs's inner step — a scope-coherent registry
(local_fn_defs) records 2-ary block lets, evicts on rebinding, and is shadow-guarded at every
binder introduction (function parameters, match-arm patterns), so a hit is always the innermost
binding; a local folder's frees resolve in the call site's own frame, so the top-level-folder
free-variable check is skipped, like a lambda's); chained updates in one slot (Map.add k2 v2 (Map.add k1 v1 m) → in-place ops innermost-first — sound because the reduce form never mutates, so
hoisting every read before every mutation preserves what each read sees); fresh-reset slots
(([], List.concat done [cur]) — the slot rebinds to a fresh init; the store-then-reset exception
licenses storing a reset slot's whole object into another slot, force-hoisting the old reference
before the rebind); Map.remove/Set.remove (→ m.pop(k, None) / s.discard(x), mirroring
the copy helpers exactly); and Var inits (a mutated slot binds a defensive shallow copy —
dict(seed), constructor inferred from the slot's op family — so the original reads unchanged after
the fold; an unmutated slot binds as an alias, preserving reduce's object identity). For soundness, a
parameterized local let in a folder body is a deferred closure and is held to the closure rule (no
sensitive mention), not the immediate-read rule.
Most stdlib members lower to an emitted _pf_* helper (a single callable so partial application
still works). But a handful are pure, total, one-liner wrappers over a Python idiom, and when such
a member is fully applied there is no reason to route through the helper at all — the same
fully-applied-collapse instinct as the currying rule. try_inline_stdlib (in lower_application,
gated on the head being a module-qualified Field and the arg count matching the member's exact arity)
emits the idiom directly: String.contains n s→n in s, String.startsWith p s→s.startswith(p),
String.endsWith→s.endswith(…), List/Set/Map.contains→x in xs (a PyExpr::Compare with the
In op, so precedence/parenthesization is free), and List.isEmpty xs→not xs. The argument order is
taken verbatim from each helper body — an inverted operand would be a silent miscompile — so the inline
form is value-identical to the helper. Anything short of full arity (a partial application like
String.contains "x", or a bare value reference) is not matched and falls through to the helper
unchanged, so List.map (String.contains "x") xs still works. The win is mostly readability:
"CHIPNHM" in line reads better than _pf_str_contains("CHIPNHM", line), and the call-elimination is
small on wall-clock (~3% on the network-rail example; a cProfile line that looked like ~6s was
mostly the profiler's own per-call overhead on a ~1.87M-call trivial function, not real runtime — see
ROADMAP.md). (List/Set/Map.len are not in this set: they already lower to a bare len, so a
fully-applied call is already len(xs) with no helper in between.)
Two further call-site shapes ride on the same lever (found on a Scrabble move generator that asked
List.findIndex ((==) l) rack two million times and match Map.tryFind rc checks: four million
times per position).
Equality predicates (try_inline_eq_predicate, right after try_inline_stdlib in
lower_application): a fully-applied List.findIndex/List.find/List.exists whose first argument
eq_predicate_value recognises as (==) x (an App of OpFunc(Eq) to one argument) or as a
one-parameter lambda whose body is x == y / y == x with y not free in x (checked with
fold_loop::collect_free) routes to _pf_index_of(x, xs) (try: return Some(xs.index(x)) except ValueError: return None_()), _pf_find_eq(x, xs) (the same with xs[xs.index(x)], because
list.index finds an element that is == to x and the generic helper returns that element, which
0.0/-0.0 can tell apart), or the Compare … In expression x in xs. The two helpers live in
list_prelude under the collection-helper registry, flagged like _pf_find_index so the Option
prelude is emitted. x lowers first and xs second, the argument order of the call they replace.
The arity gate is exact, so List.findIndex ((==) l) as a value still becomes
functools.partial(_pf_find_index, lambda b: l == b). Semantics are those of list.index/in: an
identity shortcut before ==, the same trade List.contains→x in xs already made.
Match-consumed lookups (try_lower_lookup_match, an early return at the top of both the
return-position and the value-position Match arms; is_lookup_match is the pure shape check the
value-position caller runs before allocating its temp, so a rejected shape leaves temp numbering
untouched): a match whose scrutinee flattens to a fully-applied Map.tryFind k m, List.head xs, or
List.get i xs, and whose arms option_arms accepts (exactly two, unguarded, None and Some v
in either order, v a name, _, or a tuple of those) lowers to a PyStmt::If: the test and payload
are taken verbatim from the helper body (k in m/m[k]; xs/xs[0]; 0 <= i < len(xs)/xs[i]),
each operand bound to a fresh temp unless is_atomic, v bound from the payload through
bind_ce_target (skipped for _, which is never read), and the two arm bodies lowered through
lower_arm_body (return position returns, value position assigns the match temp), with the usual
shadow_local_fns discipline around the Some arm. No Option is constructed, so a program whose
only Option was this one emits no Some/None_ classes at all. Anything else falls through to the
match, unchanged.
Lowerer::try_lower_ladder_match runs ahead of the generic match lowering in both positions
(lower_return and lower_value). ladder_shape is the gate, and it is purely syntactic: every arm
must be a constructor of one built-in family (LadderFamily::of_ctor — Some/None or Ok/Error)
whose payload pattern is simple_irrefutable (a variable, _, or a tuple of those, nested), or a
catch-all (_/a variable, which binds the whole scrutinee); arms after an unguarded catch-all are
dropped as dead. Anything else returns None and the arms lower to match/case as before. The
scrutinee is lowered once and bound to a temp unless it is already a name. Each arm becomes a
Piece (its isinstance test, the payload binding via bind_irrefutable, then the body under its
guard if any), and the chain is assembled back to front: an unguarded arm nests the rest as its
orelse (the emitter renders that as elif/else), a guarded arm cannot (its guard failing
must reach the later arms), so the rest follows it as statements, which is only sound in return
position where every body returns, hence a guarded value-position match is rejected before any
lowering happens. Exhaustiveness is the ladder's own rule (both constructors unguarded, or an
unguarded catch-all, judged on the source arms): then the last arm is emitted with no test and no
defensive raise follows. In value position the result temp is allocated before the shape check so the
ladder can name it, and handed back (tmp_counter restored) on rejection, so the match path
numbers its temps exactly as it did. short_circuit_bind gives result {}/option {} the same
shape for each let!/do! (bind_ce_target unpacks the payload; the failure is return <subject>).
Two things the pass deliberately does not do: it does not test with is against the singleton
(a hand-built Some/None_ from Python must still match), and it does not extend to user ADTs.
Lowerer::new computes nullary_singletons: every zero-field constructor in ctor_arity (user
variants plus the prelude's None, minus hidden active-pattern cases) whose singleton name
(nullary_singleton_name: _ + the emitted class name) is not in binder_names, the whole-module
binder set (module_binder_names, shared with the module-alias shadow check). lower_module emits
_Red = Red() (singleton_assign) right after each such class; option_prelude does the same for
None_ (always, in _pyfun_rt.py). nullary_value is the one place a constructor-as-value is
spelled (lower_var, the decode specializer's None), and the prelude helpers reach it through
none_value/none() (only collection_prelude constructs None values). Cross-module: the project
driver computes each module's singleton set with the same two functions and passes it as
ImportContext::nullary_singletons, so an importer emits palette._Red exactly when palette.py
defines it and palette.Red() otherwise; a project module that needs Option imports _None_ from
the runtime alongside Some/None_ unless it binds that name itself.
A match arm's capture, and a let in a nested block, both become function-wide Python locals, so one
that another binding of the same name is live across is renamed (issues #92, #96, #97 and #99). The
rule, in src/lowering/captures.rs, is liveness: binder B of name n (an arm capture or a
nested-block let) is freshened when some reference to n outside B resolves, under Pyfun
scoping, to a binding that is live across B: the frame root (a parameter, a root-level let, a
global or builtin, a binding of an enclosing frame) or an arm/nested-block binder whose extent
encloses B. A reference bound by a disjoint arm or block (a sibling arm, an arm of a different
match, another block's let) reads its own binding and does not count; a reference inside a nested
closure always counts, since the closure can outlive its arm; and B's own references never count
for B, closure or not. So case Error why: in three sequential matches keeps why, while the same
three arms under a root-level why read at the end all rename, and a lambda made in one arm that
reads why forces a later arm capturing why to rename.
The census is one walk of the frame's body (Census) that resolves every Var reference and <-
target to what it reads at that point, recorded as an Occurrence { target, in_closure }: the
target is Outside (a binding outside the frame: an enclosing function's, a module-level one, a
builtin), Root (a parameter or a root-level let of this frame), or Binder (a registered
arm/nested-block binder); must_rename counts the first two alike. Identity is
the address of the AST node (binder_key, the MatchArm or the LetBinding), which is what the
lowering holds when it decides that binder, so no spans are involved and a desugared or cloned tree
can never be mistaken for the original. Each registered binder also records its ancestors, the
registered binders enclosing it, so Frame::must_rename(n, key) can tell an enclosing binder from a
disjoint one. A binder the census never saw gets the conservative answer (every foreign occurrence
counts). On entering a nested Python scope (an ExprKind::Fn, a parameterised block let, a
computation expression) the walk marks that scope's parameters and everything it binds as Inner,
which produces no occurrence at all (a lambda's own x is its own slot), while a free reference in
it still resolves through the enclosing environment and is recorded with in_closure. A root-level
let binds to the root; a nested-block let registers itself and binds for the rest of its block,
its value having been walked first (so let x = x + 1 reads the outer x); a parameterised let
binds its name before its body, so a recursive reference is its own. Binders never produce an
occurrence. The walk mirrors the lowering's block depth, so a block is root exactly when
Frame::block_is_nested says so at that point.
Lowerer::frames is a stack of Frames, pushed in lower_fn_body (every
function and every statement-bodied lambda), in lower_ce (a built-in CE body is its own function; a
user builder desugars into the enclosing frame, so its frame is the enclosing census with the CE
body's occurrences added on top), and once in lower_module (top-level values evaluate at module
scope; a parameterised top-level let and an active-pattern recognizer are their own defs). The
fold-loop pass inlines a folder body into the enclosing frame, whose census hid that body as a nested
scope, so emit_fold_loop pushes the enclosing frame merged with the body's occurrences for the
duration. A def-emission site without its own push inherits the enclosing census, and every merge
over-counts; both only ever freshen more, never less.
Lowerer::enter_arm(arms, idx, locals) is the one place an arm is entered, used by every arm-binding
site (the return- and value-position match, the active-pattern chain and fall-through sequence, the
Option/Result ladder, the lookup peephole, the fold pass's inlined match). It extends the locals,
shadows same-named registry entries, then for each capture asks Frame::must_rename(n, key(arm))
and, when it says so, installs n → fresh in Lowerer::renames, where fresh_capture_name is _
- the name, bumped with
a counter while the frame uses it, it was already handed out in the frame, it is a binder anywhere in
the module, or it would land in the reserved
_pf_space.exit_armremoves the arm's renames and unshadows.renamesfollows the block-scoped registries' discipline (LocalScope/Shadowed): saved and restored per block, displaced by parameters, lambda parameters and inner captures (shadow_local_fns), evicted by a blockletor CE binder of the same name. Every place a binder or reference is spelled consults it:lower_var(first thing, before any routing),lower_pattern(captures, list rests, as-names),bind_irrefutable/unpack_into_asthrough their rename hook, the lookup peephole'sbind_arm_payload, the ladder's whole-value binding and the active-pattern chain's binder assignments.
Nested-block lets. Each Frame carries a block depth, bumped by enter_block/exit_block
around the three block lowerings (lower_block_return, lower_block_value, the fold pass's inlined
block) and reset by each frame push, plus root_is_body: whether the first block entered is the
frame's own body (lower_fn_body sets it when the body is a block; a computation expression's items
are not a block, so any block inside a CE is nested; the module frame treats a block-valued top-level
binding as root, since a collision with a module-level binding is already isolated by
lower_module's frame wrap). In a block that is not the root (Frame::block_is_nested, reported by
enter_block), lower_block_let decides each bound name by Frame::must_rename(n, key(let)). A
root-level let asks Frame::must_rename_root(n) instead (issue #99): rename iff any occurrence of
n in the frame is Outside. Python makes n local to the whole def as soon as anything in it
assigns n, so a read of an outer n before the let (or in its value, or in a closure made
before it) would raise UnboundLocalError; a read after the let resolves to Root(let) and a
read of a parameter or an earlier root-level let resolves to Root(that), neither of which is
Outside, so rebinding in sequence stays as Python does it. Parameters are bound as Root when
the frame is built (Frame::of_body takes them), and the module frame never renames a root-level
let (Frame::module), since those are globals. Ordering: a value let lowers its value first (references there mean the outer binding), then
installs n → _n in renames for the rest of the block, where it dies with the block's
restore_local_scope; a parameterised let installs it before its body so a recursive call resolves
to the renamed def, and the def is emitted under the fresh name. The emitted target is passed down
explicitly (lower_let's targets map, through lower_binding_as and unpack_binding's rename
hook for a destructuring let), never read from renames, since the rename is not yet active while
the value lowers. A let mut is covered: an <- target spells through py_binder_name, and the
global/nonlocal declarations lower_fn_body computes from scan_scope (Pyfun names) map
through the renames active when the nested function is lowered, which is while the outer rename is
in force.
Deliberately not counted: binders (a later let x in the frame assigns before it reads) and a
reference bound by a disjoint arm or block (its own slot in Pyfun and, by the time it runs, its own
value in Python).
Computation-expression binders. A built-in CE body (option/result/seq/async) is its own
Python def and its own frame (Frame::of_ce), and a let/let! target in it is a root-level binder
of that frame, so it takes the root-level rule: enter_ce_binder asks must_rename_root for each
bound name, evicts the name from the local-function registries, and installs any rename in renames
for the rest of the CE body (the CE's save_local_scope/restore_local_scope wrap drops it after);
bind_ce_target_as then emits the assignment under the chosen spelling, through unpack_binding's
targets for a destructuring target. The let! of result/option lowers its continuation before
its ladder is built, so lower_short_circuit_items registers the binder first and hands the spelling
to short_circuit_bind; the failure branch still returns the subject untouched. seq and async
bind after lowering the value and before the next item, so bind_ce_target does both steps in one.
Decode.decodeString dec s normally builds a runtime decoder value — a tree of raising closures —
and interprets it over json.loads output. When dec is a syntactically-known composition of the
simple combinators (string/int/float/bool/field/list/nullable/map–map4/
succeed/fail/oneOf, including through top-level let decoder names), the pass
(src/lowering/decode_spec.rs, hooked in lower_application, default-on with PYFUN_NO_DECODE_OPT
as the kill switch) deforests the interpreter into direct dict/list access with inline error
handling: one try whose body reads like a hand-written validating parser (t = v["name"],
if not isinstance(t, str): raise ValueError(...)), with the handler folding any raise into
Error(_Exception(kind, msg)) exactly like _pf_dec_decode_string. The Result is
byte-identical to the interpreter's on every input — same Ok payload, same error kind and
message (a missing field is KeyError with the key's quoted repr). Faithfulness mechanics:
configuration expressions (fan-in functions, field names, succeed values) hoist outside the
try in construction order, matching the interpreter's build-time evaluation; every node decodes from
a bound temp so a subscript evaluates once; oneOf nests try/except per alternative, exhausting to the
interpreter's exact message. Anything dynamic — andThen, a decoder passed as a value, a shadowed or
cyclic named decoder, a non-literal oneOf list, an in-file module — rejects and falls back to the
interpreter unchanged. Measured on a decode-dominated workload (400k-object JSON array into records,
in-process best-of-5): 2.8x end-to-end including the shared json.loads, ~4x on the decode portion
itself.
src/lowering/self_tail_call.rs, called from lower_binding_as (the one site that emits a named
FuncDef, so the function has a name to call itself by) after lower_fn_body returns. It runs on the
lowered body, not the Pyfun AST, which is what keeps it small: lower_return emits a
PyStmt::Return only in tail position, so "tail call" is just "a Return whose value is
PyExpr::Call on our own emitted name with our own arity", and the tail-position walk is a walk over
statements that already exist. Leading Global/Nonlocal split off first and stay outside the loop
(Python requires them at the top of the block); everything after them is wrapped in
PyStmt::WhileTrue, with each self tail call replaced by a rebinding (PyStmt::Assign for one
parameter, PyStmt::UnpackAssign from a PyExpr::Tuple for several, so the rebinding is
simultaneous) plus PyStmt::Continue. Both statements are new IR nodes, as is UnpackAssign.
The four preconditions (DESIGN §5.4) are binds (the body rebinds the function's own name — assign,
unpack, for target, nested def/class, or a match capture), has_yield (generator), the
descent rule in rewrite_stmts (into If branches and Match case bodies only — never For,
FuncDef, ClassDef or Try), and the capture check: frame_names (parameters plus everything
assigned in this frame) intersected with nested_refs_stmt, which collects each nested function's
free names (free_in_function/free_stmts/free_expr subtract the function's own parameters,
locals and match captures, and add back anything it declares nonlocal/global, since those name
the enclosing cell by definition). A non-empty intersection rejects, because the loop shares one cell
per name where recursion gave each frame its own. Any rejection returns the body untouched, which is
always correct and merely stack-bound.
rewrite returns an Outcome { body, note }. has_self_tail_call runs first, so a function with
no self tail call is never mentioned; when there is one and a precondition rejects, the note names
the function and the reason. Notes ride out through lowering::lower_collecting /
LoweredModule.notes → lib::compile_collecting / project::CompiledProject.notes → the CLI's
report_notes, which prints them to stderr for compile and run. lower and compile keep their
old signatures by delegating and dropping the notes. Regression tests live in tests/compile.rs under
"self tail calls become loops", including two end-to-end: 50k/100k-deep recursion that only completes
as a loop, and the closure case whose per-frame values the capture check exists to preserve.
A Lowerer.native flag, set by lowering::lower_native_typed (single file, called from
pyfun::compile_with) or lower_in_project (from project::compile_with). Three pieces:
- Match ladders.
try_lower_native_matchruns before theOption/Resultladder at both match call sites (return and value position).native_pattern_okdecides whether every arm can be compiled to plain tests;native_patterncompiles one pattern against a subject expression intoconds(and-ed) andbinds(assignments run once the arm is chosen). A top-level or-pattern is expanded into one piece per alternative (the body lowered per alternative); a nested one is joined withorwhen no alternative binds a name. The assembly (guards falling through in return position, the last unguarded arm as a plainelse) mirrorstry_lower_ladder_match. - Annotations.
lower_modulepost-processes the module:any_for_object_fieldsturnsobjectfield annotations intotyping.Any(mypyc cannot build anobjectfield), andLowerer::annotate_functionsadds a union alias per non-generic user sum type after its classes, spells data class fields that name a user type ("Expr"), and rewrites each top-levelPyStmt::FuncDefwhose binding type is known into aPyStmt::TypedFuncDefusingpy_type_annotationoverLowerer.binding_types(span of the binding target →types::Ty, fromTypeSpan::raw). An unspellable type leaves its position unannotated. The same pass annotates module-level values, which mypyc cannot type when another module reads them: a nullary singleton_X: X = X(), and the first assignment of a top-level value bound once. - Comparisons and closures.
native_classessetsPyStmt::ClassDef::native, so an ordered variant emits__eq__and all four orderings instead of@total_ordering(mypyc compiled==as identity on a class with__lt__alone). - Discarded matches.
Lowerer::push_discardedemits a statement-position expression. In native mode, when its value is a temp set by anifladder,discard_tempturns each assignment of the temp into a bare evaluation, since mypyc rejects a local whose every assignment is a unit call. - Build.
src/native_build.rswrites the native output to a directory, runspython -m mypyc --allow-redefinition-new --local-partial-typesover every module but_pyfun_rt.py, removes the sources and writes__main__.py.bench/mypyc_run.pyand.github/workflows/native-bench.ymlmeasure it, andtests/native_parity.py(thenativeCI job) diffs every runnable lesson, offline example andtests/native/program againstpyfun run. A file Python cannot import (01-intro.pyfun) builds as_pf_01_intro.
Emitted code targets Python 3.12+ for exactly one construct: an f-string hole may carry a nested
string literal reusing the outer " quote, or a backslash escape — both PEP 701-only.
pyfun compile --target 3.11 (src/python_emitter/py311.rs, applied by emit_for on a clone of the
IR, single-file and project paths both) rewrites each f-string a 3.11 parser would reject into an
equivalent "template".format(args…) call: literal chunks with {/} doubled, one positional {}
per hole, hole expressions moved out of the string where no quoting rules apply. The unsafe check
renders each hole through the emitter itself (emit_expr), so it is exact by construction: unsafe iff
the rendered text contains " or \. Clean f-strings stay f-strings; default-target output is
untouched. case patterns are not walked (an f-string cannot occur there, and a call would not be a
valid pattern). This exists to unlock PyPy (3.11 is its ceiling as of v7.3.22); match/case is
3.10 and fine.
pyfun lsp runs a small language server over stdio. It speaks LSP/JSON-RPC with Content-Length
framing; to keep the crate dependency-free (no serde/lsp-types), the JSON value type, parser,
and serializer are hand-rolled in src/lsp/json.rs — the same choice as the hand-rolled lexer/parser.
The message-handling core (Server::handle) is pure (JSON in → JSON out) so it is unit-tested without
spawning a process; a separate integration test (tests/lsp.rs) drives the real binary over piped
stdio. All features reuse the existing front end:
- Diagnostics — the existing type/effect/unit/exhaustiveness errors, streamed as
textDocument/publishDiagnosticson open/change (full document sync). - Hover-for-type — the inferred type of the narrowest expression, binding name, parameter, or
pattern variable under the cursor, with latent effects shown on arrows (e.g.
string ->{io} unit) plus a dedicatedEffect:line summarizing the concrete effect performed on full application (the union of the type's result-spine arrows —io/async; argument arrows are a callback's effect, not the value's, and pure values omit the line —types::effect_summary). (The same pass hands lowering the derived codecs:types::check_collectingreturns atypes::Codecs, oneCodecperDecode.autosite keyed by span plus the named table, andlowering::lower_collecting/lower_in_projecttake it beside the float-literal spans —DESIGN.md§6, "Derived codecs".) It works because the checker, in arecord-enabled pass (types::check_collecting, surfaced viaanalyze), accumulates a(span, ty)table for every expression node, binding name, function parameter, and pattern variable, then resolves each entry against the final substitution and renders it. Bindings carry atarget_span, and parameters / pattern variables carry their own spans, so a function name hovers to its full inferred signature and a parameter hovers to its element type. A destructuring binding records both: the whole target (hover the tuple, see the tuple's type) and each name inside it, at its own span. A##doc attached to a top-levellet/type/extern(DESIGN §7) is appended below the type when hovering the declaration name or any reference resolving to it (resolve::symbol_at→ the item'sdoc); a documented symbol with no recorded type hovers to the doc text alone. - Go-to-definition — jump from a reference to its definition, module-level or local. Backed by
a dependency-free name resolver (
src/lsp/resolve.rs) that walks the parsed AST (independent of the type checker, so it works on any program that parses):definitionscollects module-level symbols (top-levellets with their precise name span; constructors / type / record decls /externs at their declaration), andreferencesresolves every identifier occurrence to aTarget— either aLocalbinder (function parameter, block-locallet, pattern variable, or computation-expressionlet/let!, resolved to the binder's own span) or aModulesymbol (resolved by name againstdefinitions). The walk tracks lexical scopes so an inner binding correctly shadows an outer one. - Find-references — every occurrence of the symbol under the cursor (the inverse of
go-to-definition, reusing the same resolver). The cursor may sit on a use or the definition/binder
itself:
symbol_atmaps the offset to its occurrence span and aTarget(the narrowest enclosing reference / local-binder / definition span wins), thenfind_referencesreturns all references with that target plus, whencontext.includeDeclarationis set, the declaration(s). - Rename — rewrite every occurrence (declaration included) of the symbol under the cursor to a new
name, returned as a
WorkspaceEdit. Built onsymbol_at+find_references;prepareRenamevalidates first and returns the identifier's range. Only locals and top-levelletvalues are renameable — their every occurrence is a precise span; constructors / types /externs are refused, because their declaration span covers the whole declaration and their type-annotation uses aren't tracked as references, so a rename would be unsound. The new name must be a valid lowercase value identifier. No capture-avoidance check is done (the editor shows the diff for review). - Completion — in-scope module symbols (from whatever the recovering parser produced, so even a
partially-typed file contributes its symbols) plus the always-available prelude (
PRELUDE+LIST_PRELUDE), builtins (Ok/Error, the builtin/reserved type names), and keywords, each tagged with aCompletionItemKind. The static set is the fallback when nothing parses. - Document symbols — the editor outline: every module-level definition as a flat
DocumentSymbol[], reusingresolve::definitions(each with a preciserange/selectionRangeand an LSPSymbolKindicon). Works on whatever parsed.
Cross-file navigation (with file-based modules, DESIGN §6.1): go-to-definition on a qualified
reference to an imported file module (Geometry.area, Geometry.Circle) jumps to the definition in
that module's .pyfun (resolve::qualified_at records expression-position qualified refs with spans;
the server resolves the sibling URI and locates the member via resolve::definitions);
workspace/symbol searches every definition across the project directory's .pyfun files; and
find-references + rename for top-level let values and constructors span the whole project
(Server::symbol_occurrences scans the directory's files and collects the definition, its bare uses in
the defining file, and every qualified use elsewhere, rewriting only the member identifier via
member_subspan so the Geometry. qualifier is preserved). A constructor's uses include both
construction expressions and patterns (Pattern::Ctor and the type variant declaration each carry a
name span). Rename fires only for a top-level value or constructor and a strict scan refuses
rather than do a partial rewrite if any project file fails to parse. Type names navigate and rename
across files the same way: Server::type_target resolves the occurrence under the cursor (an
annotation, bare or Shapes.Placed; a record tag; the declaration name) to its declaring module, and
Server::type_occurrences scans the project (declaration and bare uses at home; qualified uses, unshadowed
bare uses, and a record's qualified tags in importers). resolve::type_references exposes the raw
occurrences; a qualified expression counts as a type only when it is a record's tag, since a sum type's
same-named constructor is a value. A type renames to an uppercase type name; builtins are refused.
Resilient & incremental analysis. A half-typed file still yields results. The parser has an
error-recovering entry point (parser::parse_recover → (Module, Vec<ParseError>)) used by the editor
(the compiler keeps the strict parse, as it must reject any broken program): on a failed item it
records the error, guarantees forward progress, then synchronizes to the next item boundary (a
statement separator at block depth 0, tracking Indent/Dedent so a separator inside a broken
block isn't mistaken for it). So one broken let no longer hides the rest of the file — the items that
parse still drive hover and navigation, and only the syntax errors are reported until the file is
clean, at which point the type errors take over. analyze returns an
Analysis { module, diagnostics, types, parse_ok } bundle; lexing errors remain fatal (no AST) and
rename requires parse_ok. The "incremental" half is a per-document analysis cache keyed on a
monotonic version stamp: repeated requests on an unchanged document reuse one parse + type-check.
Project-wide cache + import invalidation. Import-aware analysis is cached at two levels, both
validated by content fingerprints (a DefaultHasher of the source text; an analysis is a pure
function of the entry text plus every imported source it consulted, so equal fingerprints prove an
equal result). (1) Each per-document cache entry (CachedAnalysis) records the imported module files
its analysis consulted — deps: (uri, Option<fingerprint>), with None recording the file's absence
so that creating it later also invalidates. A cache hit requires the document version and every dep
fingerprint to match, so editing an imported file — in an open buffer or on disk — re-analyzes its
dependents on their next request. (2) A project-wide exports cache (Server.exports,
CachedExports keyed by module-file URI) memoizes each imported module's checked interface
(ModuleExports) together with its own dep list, so two open documents importing Geometry share one
parse + check of geometry.pyfun. Imported sources are read from the open buffer when the file is
open (else disk). The resolver (Server::resolve_exports_cached, driven through
lib::analyze_with_imports) mirrors the forgiving project::resolve_imports semantics — missing/
broken/cyclic imports are omitted — with one care point: an interface computed in an import-cycle
context is context-dependent, so such "tainted" results live only in a per-pass memo and never enter
the project-wide cache. Diagnostics for a dependent are still published only when that document is
next analyzed; proactively re-publishing dependents' diagnostics on an import edit stays deferred.
The AST changes that enable local navigation: function/binding parameters are Param { name, span }
(was Vec<String>), Pattern::Var { name, span } (was Var(String)), and LetBinding and the
CeItem::Let/LetBang variants carry a target: Pattern plus its target_span (each was a
String name and its span, before a binding could destructure — LetBinding::name() is the
plain-name shortcut every phase keeps for the common case, and bound_names / bound_vars are how
a phase asks for all of them). The spans are NodeSpan (which compares equal
unconditionally), so roundtrip/structural equality is unaffected; lowering erases them (param_names).
Deferred: truly incremental reparsing — an edit still re-analyzes the whole document — and
deliberately so: a whole-file lex + parse + check is milliseconds at realistic Pyfun file sizes, the
two caches above already eliminate all redundant whole-file work, and region-based reparse would
complicate the offside-rule lexer and the recovering parser for no perceptible latency win at this
scale. The editors/vscode/ client is intentionally thin — all language smarts live in the Rust
server.
Two mechanisms, both in src/lowering/mod.rs, for the two shapes of collision between user names
and the namespace the emitter shares with them.
Mangling (py_value_name, and py_field_name for record attributes) is a pure function over
three fixed lists — PY_KEYWORDS, PY_EMITTED_BUILTINS (the non-type builtins the emitter calls:
len, map, filter, isinstance, …) and PY_BUILTIN_TYPES (also the legal roots of a dotted
extern target) — appending _ to any name they claim. Being a pure function is what lets a
definition and a cross-module reference agree without coordination, so it must stay one. Two names
are deliberately absent: print/abs/min/max/round lower name-for-name from identically
spelled Pyfun prelude bindings, so a user binding shadows the Pyfun name too and the checker settles
it; and id never reaches the output (Pyfun's id is _pf_id), so mangling let id x = x would be
a tax with no collision behind it.
It is applied only where a name is written into the Python IR — parameters (py_param_names),
lower_binding_as, lower_var, pattern captures, <- targets, nonlocal/global lists, CE
binders, qualified member references, the in-file Module_member spelling, and the fold pass's slot
and loop variables. Scope tracking (locals, scan_scope, fn_local_stack, binder_names, the fold
pass's plan) stays in Pyfun name space: mangling a set those passes consult would silently break
shadowing.
Aliasing (Lowerer::extern_path) covers imported Python modules, which are arbitrary
(math, numpy, httpx) and so cannot be a fixed list. It consults the same whole-module
binder_names set py_module_ref uses for imported Pyfun modules: on a collision the import
becomes import math as _pf_math and the reference is re-rooted, leaving the user's binding alone. A
dotted import binds only its first segment (import os.path binds os), so the alias replaces as
many segments as the module spec covers.
EffLabel::Spawn is performed by Task.start (its scheme, seed_task_prelude). The discharge
is a rule in Infer::infer_apply, not in unification: for a saturated application whose head is
the prelude's Task.scope (qualified_name, and no user binding named Task), the argument is
inferred in a fresh effect context and spawn is removed from what it performed before the rest
joins the enclosing effect (infer_apply_plain is the ordinary path). types::run collects each
top-level evaluation's performed effect (value bindings, expressions, module value members) in
top_effects and reports any that still contain spawn once inference is done. Lowering routes
Task.scope/Task.start to _pf_task_scope/_pf_task_start (async_prelude) and
note_task_scope_var defines or imports _pf_scope.
Cold async (DESIGN §8). lower_async returns Lowerer::cold_async([g]), a
_pf_Async(g) call, and records _pf_Async in needed_async_helpers; async_prelude emits the
class as a PyStmt::Class (the plain-class IR node) and adds _pf_Async/_pf_run whenever a helper
that needs them is used. Every Async/Task helper is a plain def returning _pf_Async(run)
around an inner async def run(). At the boundary, Lowerer::new records each extern's
async_param_slots (AsyncSlot::Value/Callback/Thunk) and whether it returns_async;
wrap_async_args runs after wrap_thunk_args at every extern call site, and lower_application
wraps a saturated Async-returning extern call with cold_extern_result (_pf_Async(f, *args),
or a functools.partial for pinned keywords). In a project, async_extern_binding gives such an
extern an adapting lambda, since a dependent knows only its arity; lower_var_value uses the same
lambda for a bare reference, and lower_application a functools.partial over it for a partial
application. _pf_Async.__call__ gives Python code a fresh coroutine (asyncio.run(job())). The emitter prints
await _pf_Async(f, *args) as await f(*args). The async self-tail-call rewrite
(rewrite_async_wrapper) matches return _pf_Async(g), gives g the parameters and returns
_pf_Async(g, *params).
An Effect is a set of concrete labels plus a set of effect variables, so unification is over an
idempotent union semilattice, not a free algebra. unify_eff resolves both sides through the effect
substitution and then tries, in order:
- equal — nothing to do;
- a bare variable absent from the other side (
X ~ S,X ∉ S) — bindX := S, the most-general solution; - a bare variable that occurs in the other side (
X ~ S ∪ X) — bindX := S, i.e. the other side with the recursive occurrence removed. Idempotence makes this exact, and every other variable inSstays open; - two closed effects with different labels — fail (declared effects are exact, DESIGN §4);
- anything else — widen conservatively: close every variable involved to the joined label set.
Case 3 exists because tying a recursive knot produces exactly that equation. Each application in a
body performs a fresh latent variable into cur_eff (infer_apply), so a recursive call of arity
n contributes n of them, and the binding's own type puts the accumulated body effect on the innermost
arrow with the currying arrows pure (infer_binding). unify recurses into the codomain before
unifying an arrow's own effect, so the innermost equation — which mentions the outer arrows'
variables — is solved first. Widening it (case 5) closed those outer variables to {io} as well,
contradicting the pure outer arrow and rejecting every effectful recursive function of arity ≥ 2:
the shape of every loop in a language with no while.
The surface semantics (a ?/?name hole is a typed blank the checker reports and that blocks
compile/run) are in DESIGN. Implementation: ? is lexed as Tok::Hole(Option<String>) (a name is
lexed adjacently, like f"/r"); the parser produces ExprKind::Hole { name }, which round-trips. A
hole infers as a fresh type variable that unifies freely, so it never causes a spurious error and
takes whatever type the context demands (?body + 1 ⇒ int, List.map ? xs ⇒ a function type); it is
recorded (Infer::holes) and, once the substitution is final, resolved and rendered
(types::Hole { name, ty, span }). It is reported informatively: pyfun check prints each as a
note (hole `?body` has type `int`) and the LSP publishes it at Information severity (3)
with hover showing the type. A hole blocks compile/run with a "cannot compile: unfilled hole"
error, and check exits non-zero.
Valid hole fits. Each note lists in-scope bindings that could fill the hole — the compiler searches
the environment snapshotted at the hole and reports every binding whose type unifies with the hole's.
The test is a real trial unification rolled back afterward (Infer::hole_fits snapshots the
substitution maps, instantiates each candidate scheme, unifies against the resolved hole type, and
restores). Fits are ranked by the hole's own name first (name_affinity: ?upper puts a member named
upper ahead, then names containing it), then most-specific (fewest generalized variables), unqualified
names before qualified module members, and prelude declaration order (prelude_rank) rather than the
alphabet. The list is capped at 6 and says and N more when it cuts; a fully-unconstrained hole ('a)
lists none. Refinement fits
go further: a function binding whose result — after applying one or two arguments — unifies with the
hole's type is reported applied to that many further holes (String.upper ?, String.concat ? ?).
Infer::hole_refinements peels leading arrows off each candidate (up to MAX_REFINE_DEPTH = 2) and
trial-unifies the tail, skipping a peeled result that is a bare variable — a structural filter that
keeps out trivially-general combinators (id, const). Name match, then fewest-holes-first, capped at 4, never
duplicating a direct fit.
Separate from the LSP's semantic smarts, editors/vscode/pyfun.tmLanguage.json gives static,
parse-free highlighting (keywords, declarations, types/constructors, numbers + adjacent unit
annotations, operators, strings/comments). One deliberate design choice: the escape-hatch tokens are
flagged in a caution colour to signal the opt-outs from Pyfun's immutable-by-default / effect-checked
defaults — mut (the mutability opt-out), <- (the act of mutation), and extern (the untyped,
effectful-by-default Python FFI boundary). pure deliberately stays a neutral storage.modifier (it
is an encouraged assertion, the opposite of an escape hatch), and -> is scoped apart from <- so
only the reassignment arrow is flagged. The colour is applied via TextMate scopes plus a pinned
foreground, not by borrowing a "warning" scope: mut → storage.modifier.mutable.pyfun, <- →
keyword.operator.mutation.pyfun, extern → keyword.other.extern.pyfun (each names what the token
is), and the extension pins all three to an amber #CC5E00 (no italic) via
contributes.configurationDefaults.editor.tokenColorCustomizations. Pinning the colour rather than
relying on a theme's rendering of invalid keeps the hue consistent across themes and light/dark
auto-switching, and avoids the semantic lie that these valid keywords are errors. Users can override the
colour in their own editor.tokenColorCustomizations.
project::locate_module(dir, name) is the one resolution rule, used by build_from_path (the CLI
graph) and resolve_exports (the LSP): the sibling name.pyfun, else the same file in each
directory under <root>/.pyfun/facades/ for the nearest pyfun.toml (manifest::find_root,
manifest::facade_dirs). src/packages.rs implements the commands: the installer is uv pip
when uv is on PATH, else python -m pip; the façade's .pyfun files are found by asking the
installed distribution for its file list (importlib.metadata), and manifest::set/remove edit
pyfun.toml line by line.
The module system's semantics (one module per file, qualified use, an acyclic import graph, cross-module ADTs/records/externs/measures, all-public) are in DESIGN.md §6.1. The driver, cross-module checking, and project emit are built as follows.
Resolution & ordering (src/project). Implementation: project::build(entry, load) walks the graph depth-first with an
injected load: Fn(&str) -> Option<String> loader, so the graph/cycle/topo logic is filesystem-free
and unit-testable; a back-edge to a module on the DFS path is a ProjectError::Cycle (reported as the
path A -> B -> A), a None from the loader is a ProjectError::Missing (naming the importer), a
lex/parse failure is a ProjectError::Compile (naming the module), and the DFS post-order is the
returned topological order (dependencies first, entry last). project::build_from_path(entry) is the thin
.pyfun-file wrapper (module name = stem with first letter uppercased; import Geometry → geometry.pyfun
in the entry's directory). Cross-module checking and emit consume this Project.
Cross-module checking (types::check_module + project::check). Implementation: the single-file run was generalized to take the imports map and return the module's
ModuleExports interface: its exported value schemes (which include extern names), its exported sum
types, its exported records, its opaque handle types (collect_exported_opaques /
ExportedOpaque: name + arity, issue #72), and its measures + measure-aliases; check_module(module, imports) seeds imported values under qualified keys and imported sum types into the decls under qualified
constructor keys. The type merge happens in two halves, because a local type declaration may name an
imported type (type Holder = { item: Shapes.Placed }, DESIGN §6.1): merge_imported_type_names runs
inside build_decls, between pass 1 (local type names, which therefore win a clash) and pass 2 (local
type bodies, which can now resolve an imported name), registering each imported type under its bare
identity name and a qualified key at the same arity, and returning the accepted (module, name) pairs;
merge_imported_types then fills in the bodies for those pairs after build_decls returns, which is when
the local field registry already exists so local records lead the field multimap. A written qualifier is
validated against the qualified key and folded back to the identity name in resolve, so Shapes.Placed
and a bare Placed are one Ty::Con. Body merge: (merge_imported_types: decls.ctors["Geometry.Circle"], type_ctors["Shape"] = ["Geometry.Circle", …]), plus imported records under their bare identity name with a qualified surface
alias (decls.record_aliases["Geometry.Point"] = "Point") and their fields appended to the field multimap,
plus imported measures merged unqualified into decls.measures/measure_aliases (with the alias-conflict
check), so construction (the Record/Field arms), qualified ctor/record patterns (bind_pattern),
exhaustiveness (ctor_signature), and <…> unit resolution all resolve with no special cases. Transplanting a scheme across modules is sound
because a top-level binding (and a constructor) generalizes against an env of closed schemes, so its own
scheme is closed — but closed is not enough: every module allocates variable ids from its own counter
starting at RESERVED_VARS, so an imported scheme arrives holding ids the consumer will hand out again.
instantiate alone survives that (it refreshes the quantified vars before substituting), yet env_free_vars
applies the local substitution to every env scheme, so a single id collision rewrote the imported scheme
and leaked local variables into the "free in the env" set — blocking generalization of the consumer's own
bindings, which then exported un-quantified and cascaded onward (#26). Every imported scheme is therefore
alpha-renamed into the consumer's id space as it is seeded (Infer::refresh_scheme, values and constructors
alike, in sorted order so the ids do not depend on HashMap iteration).
Transitive interface closure (issues #72/#75): before run hands back its ModuleExports,
close_over_references walks every exported scheme, constructor scheme, and record field for Ty::Con
names (collect_con_names) and pulls any name satisfied by an import's record/opaque tables into this
module's own exports, tagged origin: Some(declaring module) (own declarations carry origin: None);
newly pulled records are walked in turn, and a seen set terminates the walk on mutually referencing
records. Each import's interface is itself closed, so one pull per hop reaches any depth. On the consumer
side merge_imported_type_names registers carried entries last and under the bare identity name
only (no qualified key, no record_aliases entry, so constructing or pattern-matching a carried record
still requires the direct import); a taken bare name skips the carried entry silently (record_home
recognizes the same record arriving along two paths). By-name field lookup applies the same precedence in
tiers (Infer::field_owner_tier, consulted by record_of_field and the pending-field fallback): local
and directly imported records decide first, and a carried record owns a field only when no direct one
declares it, so a carried record can never make an existing access ambiguous; a tie between carried
records with no direct owner is still the ambiguity error, naming the tier that decided. A genuinely
shadowed record's fields land in
Decls::field_hints, which record_of_field / record_of_field_on render as "the record Config in
module Inner declares this field" instead of a bare unknown. Constructing or matching a carried record
bare is refused with the fix spelled out (resolve_record_tag via Decls::carried_record_home: "the
record Config is declared in module Inner; import Inner to construct its values here", the pattern
site says "to match"), while a name that is no record at all keeps "not a record type". The lowering side:
ModuleExports::carried_records feeds project::compile's per-module ImportContext::record_class_modules
plus field data keyed by the declaring module's tag, so a record update in a consumer that never
imports the declaring module still reconstructs via the right class (inner.Config(...),
Lowerer::record_class_name accepts those modules and hoists their imports).
project::check threads the ModuleExports map through the topological order, seeding each module from
only the modules it actually imports (so an unimported module's members/constructors stay invisible), and
returns errors grouped by module. Lowering routes a qualified constructor — in expression or pattern
position — to the imported module's class (geometry.Circle, dotted class pattern case geometry.Circle(r):,
with import geometry hoisted); a nullary imported constructor used as a value is called
(palette.Red()), and imported constructor arities are threaded so a partial application still curries.
Output & the shared runtime (lowering::lower_in_project + project::compile). Implementation: the
Lowerer gained an imported_modules set (drives the geometry.area routing) and a use_runtime flag
(emit from _pyfun_rt import … vs inline); lower_in_project(module, ctx) sets them and threads
ctx.member_arities (the imported functions' arities) into the arity table so a cross-module partial
application still lowers to functools.partial. project::compile builds each module's ImportContext
from its imports, emits <name>.py per module, and appends _pyfun_rt.py (via runtime_module()) iff
any module used the nominal classes.