Compilers — Build a Small Language from Start to Finish
The Grammar Is Right but the Meaning Is Wrong — Semantic Analysis
In one line
The tree the parser builds is only syntactically correct. Semantic analysis walks that tree once more and checks, before running, which declaration each name refers to (name resolution) and whether the types match where they can be known, and it collects every mistake it finds and reports them in position order.
Why this was needed
print cnt; has no syntactic flaw. But if the declared name was count, the interpreter stops only at the moment it reaches that line — and if that line is in a settlement branch that runs once a month, a month later. Mistakes that can be known without running should be reported before running. Semantic analysis decides where that boundary is.
- Names: which declaration a used name refers to. If there is none, it is an error; declaring twice in the same scope is an error; and reading itself while being declared, as in
let x = x + 1;, is an error. - Structure: a
returnoutside a function, and a call with a different number of arguments. - Types:
1 + true, and anif (n)whose condition is an integer.
The result of name resolution is not only errors. A table that says "this x is that x on line 3" becomes material for later stages. Thanks to this table, the interpreter does not confuse shadowed names, and the compiler uses it to decide the slot numbers of local variables.
How it works
Scopes are a stack. The bottom is global, and each time you enter a block or a function, you push an empty dictionary and discard it when you leave. When looking up a name, you look from the inside out. So the same name on the inside hides the outer one (shadowing). If you do not discard the dictionary when leaving a block, inner names leak outside — a scope leak.
let x = 1; 스택 [ {x:1:5} ]
{ 스택 [ {x:1:5}, {} ]
let x = true; 스택 [ {x:1:5}, {x:3:7} ] ← 여기서 x 는 3:7 (bool)
print !x;
} 스택 [ {x:1:5} ] ← 안쪽 사전을 버린다
print x + 1; x 는 다시 1:5 (int)
Declarations go in in two steps. let first puts the name in as "not yet defined," checks the initial value, and then changes it to "defined." If you read your own name in the same scope in between, it is a "read in its own initializer" error. A function, on the other hand, puts its name in completely first and then checks the body. That is what lets the body call itself (recursion). The parameters and the outermost declarations of the body share one scope — fn f(a) { let a = 1; } is a duplicate.
Types are looked at only where they can be known. Mini does not write types on parameters. So the p in fn f(p) { return p + 1; } has an unknown type. If you flag even the unknown places as errors, perfectly good programs are rejected (false alarms), and if you treat the unknown as "anything," you tell lies later. So there is one rule — check only when both types are known, and skip otherwise. The skipped places are checked once more by the interpreter at run time. This approach is called gradual type checking.
| Expression | Requirement | Result |
|---|---|---|
+ - * / % ^, prefix - |
both int | int |
< <= > >= |
both int | bool |
== != |
both the same type | bool |
! && || |
bool | bool |
The condition of if and while |
bool | — |
| A call | If it is a function called directly by name, the argument count must match | Unknown |
Errors are listed not in the order found but in position order. The order in which the tree is walked can differ from implementation to implementation (whether you look at the operator node first or the operands first), but the order the user reads is top to bottom of the file.
What it looks like in the field
- Where you draw the end of static checking defines a language's character. Python finds out even name errors only by running (which is why separate tools such as mypy and pyright exist), and Java checks types and names entirely at compile time. Rust goes further and checks at compile time even how long a value stays alive and who may change it — that story is covered by the course Rust — What the Compiler Refuses while reading error messages. The scope stack of this module is the ground all those checkers stand on.
- Shadowing warnings. Many linters make "shadows an outer variable" a warning. It is not an error, but bugs from confusing scope leaks with shadowing are common.
- The cost of false alarms. If a static checker keeps flagging perfectly good code as errors, people turn the checker off. The rule "skip what you do not know" looks loose, but it is the rule that keeps the checker on.
What you will do in the next lab
In checker.py, you build in turn the scope stack, name resolution (blocks, shadowing, reading an own initializer), checks for functions, return, and calls, type checking that looks only where types can be known, and condition types and result collection (analyze). The grader compares the error list and the name resolution table against the reference as a whole, and checks whether there are no false alarms on randomly generated correct programs and whether the same errors are raised in the same places on programs broken in one spot each.