TT Lab
Get started
Learn Learning paths Courses

Compilers — Build a Small Language from Start to Finish

The Grammar Is Right but the Meaning Is Wrong — Semantic Analysis

Continue in TT Lab

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.

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

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.