Wado

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:

Implementation, staged as three units of work:

  1. Parsing (Stmt::Item(Box<Item>)): a new lookahead, at_local_item_start(), disambiguates flags/type as item-start keywords from their use as plain identifiers (let flags = 10; still parses as a variable). bind_item/unparse_item/visit_item are reused unchanged for scope checking, formatting, and semantic tokens.
  2. 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) that TypeLookup consults 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.
  3. TIR emission: a local item has no Item::Struct/Item::Newtype entry in module.items for reify to walk (it lives inside a function body), so — mirroring the pre-existing anonymous-struct-literal mechanism — the full TirStruct/TirNewtype is 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.

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.