Semantic Analysis
The semantic analyzer's job is to turn a merely parsed program into an understood one: every expression gets a resolved type, every name gets a meaning, and anything inconsistent is rejected with an error. It never rewrites the program — it annotates the AST in place, and the borrow checker and codegen simply read those annotations.
Two Passes
It makes two passes over the merged AST:
- Declaration collection — a shallow sweep that registers the names of things: classes, interfaces, enums, and function signatures. This is what lets code call a function or use a type that is defined further down the file. Generic definitions (classes, interfaces, enums) are registered as templates, not concrete types. Bodies are not analyzed yet.
- Full analysis — a deep walk of every statement and expression. Each
node is checked against its context and stamped with a resolved
sun::Type.
Scope Tree
All name resolution happens against a scope tree that mirrors the
program's structure: a global scope with module, import, function, and block
scopes nested inside it. Looking up a name walks from the innermost scope
outward, also consulting using imports and, for code that came from a
.moon library, the scope of the module that defined it. Function scopes
additionally carry context the analysis needs later — whether the function
may throw, and its return type (used to infer types in return position).
The Deep Walk
During the deep walk, a few things are happening at once:
- Type inference — the type of every expression is computed bottom-up
(a literal is an
i32, a call has its callee's return type, and so on). - Expected-type propagation — types also flow top-down where context
determines them: a variable's annotation shapes its initializer, a
function's return type shapes its
returnexpressions, a parameter type shapes its argument. This is how integer literals adopt the right width and howOption.Nonelearns its type arguments. - Checking — assignments, arguments, and operators are verified for
compatibility; overloads are resolved against the actual argument types;
calls to throwing functions must sit inside
tryor a throwing function; enum matches must be exhaustive. - Argument conversions — once a call's signature is settled, each
argument is tagged with how it reaches its parameter (
ArgConversion: pass by value, move, borrow, class to interface, numeric widening, and so on —include/semantic_analysis/argument_conversion.h). The rules live insun::conversions::classifyArgument; codegen only carries the tags out. - Capture detection — lambdas are scanned for references to outer variables so codegen can build their closure environments.
Generics
Generics are monomorphized here, not in codegen. When analysis meets
Vec<i32> or Option<f64>, it instantiates the template with the type
arguments bound: annotations inside the template resolve against those
bindings, and the resulting concrete type (plus, for classes, a cloned and
re-analyzed copy of the method bodies) is recorded on the template's AST
node. Codegen later walks those recorded specializations and emits each one
as ordinary non-generic code.
Instantiation happens lazily at the first use, but the template's body is
analyzed in the scope the template was declared in: every generic
template records its definitionScope when registered, and the instantiators
switch currentScope to it (ScopeSwitchGuard) before pushing the transient
type-parameter / class / function scopes. So a body sees exactly the names
its author could see — its module's private helpers, the module's usings,
transitive dependencies — and never the requester's locals or imports. Only
the resulting specialized type is registered back in the requesting scope
(as a lookup fast path); identity lives in the global TypeRegistry and the
specialization list on the template's AST.
Access Control
Every declaration record (FunctionInfo, ClassType, InterfaceType,
EnumType, the Generic*Infos, ModuleScope, class/interface members)
carries a sun::Visibility and — except members, which use their type's — a
QualifiedName whose owner() (modulePath) is the module that declared it.
modulePath differs from scopePath for nested items: it never contains
class or function segments. The single predicate is
isAccessibleFrom(from, visibility, owner) (include/semantic_analysis/visibility.h): public,
or from is the owner module or one of its descendants. Contents of an imported .moon live
under a $hash$ scope segment, so importer code is never inside a bundle
module and bundle-private items are hidden by the same rule.
Enforcement lives in two places:
- Module-level items are filtered inside the scope lookups
(
AccessFilterinsemantic_scope.h, used bylookupInChain,lookupFunction,resolveNameWithUsings,findSymbolInModule, ...). The analyzer implementsAccessContext, which tells lookups which module is asking and reports a denial ("helperis private to modulesun") when the only candidates were private. - Class / interface members are resolved on the type
(
SemanticAnalyzer::accessibleField / accessibleMethod), which check the member's visibility (owner = the type's module) whereobj.memberis resolved.
Because generic bodies are analyzed inside their definition scope, "which
module is asking" is always the nearest Module scope on the stack — no
override is needed. deinit is always public (methodVisibility). Wording
of every diagnostic comes from sun::access::denialMessage
(access_checker.h).
Source Layout
The analyzer's source is split by concern under src/semantic_analysis/:
| File | Responsibility |
|---|---|
declaration_collection.cpp | Pass one: registering declared names |
analysis.cpp | The main expression walk |
type_inference.cpp | Bottom-up typing |
type_conversion.cpp | Annotation resolution and type-parameter substitution |
scope_variables.cpp / scope_lookup.cpp | The scope tree |
classes.cpp, interfaces.cpp, enums.cpp | Per-construct analysis and generic instantiation |
captures.cpp | Closure captures |