WEP: Local Item Definitions
Context
struct/enum/variant/flags/type/impl/trait were module-level-only
declarations: a type needed only inside one function still had to live at
module scope, polluting the module's namespace and separating the type from
its one use site. Rust, Go, and TypeScript all allow a type declaration inside
a function body; Wado had no equivalent.
Decision
Allow struct/enum/variant/flags/type (newtype)/impl/trait as a
statement inside a function or method body (Stmt::Item). fn/use/
interface/global/world/test/resource remain module-level only —
nesting a function inside a function is a separate, unrelated feature this
WEP does not attempt, and an effect interface has no meaningful function-local
scope to speak of. Each of these produces the same parse error a missing }
would ("expected }"), not a special-cased message — they were never valid
here.
Scoping rules, matching Rust's:
- Block-scoped. A local item is visible in the block that declares it and
nowhere else, so a nested
if/while/forbody cannot export one to the rest of the function. Two unrelated blocks may declare same-named local items without collision. - Hoisted within the block. A local item is in scope for the whole of its block, so a use may precede the declaration statement and one local item may name another declared later in the same block.
- Always private. A
pub/internal/exportprefix on a local item is a dedicated parse error (at_visibility_prefixed_local_item_start) rather than the generic "expected}" recovery every other invalid block-level keyword gets — the shape is common enough (a reasonable guess coming from module-level syntax) to warrant a clear, on-topic message instead of leaving it to recovery.
Implementation, staged as three units of work:
- Parsing (
Stmt::Item(Box<Item>)): a new lookahead,at_local_item_start(), disambiguatesflags/typeas item-start keywords from their use as plain identifiers (let flags = 10;still parses as a variable).bind_item/unparse_item/visit_itemare reused unchanged for scope checking, formatting, and semantic tokens. - Resolution: each local item is minted under an internal mangled name
(
{name}@{AstId:?}) — the storage identity — while the declared name is registered in an ephemeral table (ModuleDecls::fn_local_*, filled from a block's own declarations before its statements are walked and restored on the way out) thatTypeLookupconsults with the highest precedence. This two-tier split is what makes the two scoping rules above hold at once: the ephemeral, bare-name tier gives correct block visibility during that one function's resolution, while the durable, mangled-name tier (an existing table, previously used only for anonymous struct literals) is what a later, independent reify pass recovers a declaration's field/case info from, without needing to replay the scoping. Mangling is what lets two sibling blocks declare colliding names into the same shared, module-scoped durable table safely. Hoisting runs in three steps, because a name resolves through its field info: every local struct of the block gets its identity and a fieldless entry first, then the newtypes resolve (to a fixpoint, so a base may name a later newtype), then the struct fields are filled in. - TIR emission: a local item has no
Item::Struct/Item::Newtypeentry inmodule.itemsfor reify to walk (it lives inside a function body), so — mirroring the pre-existing anonymous-struct-literal mechanism — the fullTirStruct/TirNewtypeis built once during annotate and flushed into the module's TIR the same way anonymous structs already are.
A pre-existing early type-name validation pass (a fast pre-check that runs before the real elaborator, built from a flat module-wide name set) had no notion of function-local names and needed widening: it now also recognizes any name reachable via a local item declaration anywhere in the enclosing function, so it does not false-positive ahead of the real resolution. This is a coarse over-approximation on purpose — it does not itself enforce block scoping; the real elaborator still does.
Generic local structs (struct Box<T> { value: T }) reuse the module-level
generic-struct machinery unchanged: the monomorphizer already treats any
TirStruct with non-empty type params as a template to expand, regardless of
whether reify or this WEP's eager annotate-time emission produced it.
Known gaps
Cases of a local enum, variant or flags
A local enum, variant or flags parses, but no reference to one of its
cases resolves. A case path fails as unknown identifier (Color::Red), and a
variant construction as unknown function (Shape::Circle(1)), not with a
message of its own. Only a local struct and a local newtype work in full.
Methods on a local type
A local impl parses, but its methods never reach a call, which fails as
no method 'x' found on type. Two module-wide registries are built before any
function body is walked, and a local impl needs both.
TraitEnvindexesmodule.items, so no local block reaches it. A block whose target is a function-local declaration is scoped by that declaration's identity, since two sibling blocks' same-named types are two declarations.Signaturesis assembled between the declaration pass and the body walk and is read-only after. A localimpl's target is a block-local type whoseTypeIdis interned during that walk, so its method signatures cannot exist before the freeze.
Two things reach past the two registries. The orphan rule reads a function-local
target as foreign, because type_decl_index holds module-level declarations
only. And a TraitEnv that reaches a local block while Signatures does not is
worse than the gap: the header resolves where no signature exists, so the
diagnostic becomes a panic.
Self in a local struct
A local struct's Self is that struct. Inside an impl or trait method, the
compiler resolves Self in the struct's field types to the type the impl is
for instead. In a trait default method this also makes the struct differ per
inheriting impl, while it is emitted once for all of them.
