Chapter 08 written ahead of the code

Semantic Analysis and Better Errors

Everything so far either parses or it does not, and everything that parses either types or it does not. Neither of those passes has ever asked whether a name exists, whether a function is being called with the right number of arguments, or whether a function that promises a value can actually finish without giving you one. This chapter adds the pass that asks those questions, and it adds something just as important: a parser that survives its first mistake long enough to find the second and third one in the same run.

Read this before you start

Chapters 1 through 7 were written from code already in the repo, and I read every one of them again before writing this page so the names here match theirs exactly. This chapter is written ahead of the code. The instructions are exact and the C is written to compile, but I have not run it, so if something does not build, the mistake is probably mine and not yours. Tell me and I will fix the chapter.

Files you touch6
New files2
New node kinds0
Traps3

No code in this step. Before you write a new pass, you should be able to point at a program that is wrong today and watch izvor say nothing about it.

Chapter five's evaluator catches an undefined name, but it catches it inside eval's NODE_IDENT case, and eval only runs on a piece of the tree once control actually reaches it. That is a run time check wearing the clothes of a compile time one. Here is the program that exposes the difference.

a program izvor accepts todayand should not
1
if 1 < 0 {
2
    print oops;

oops was never declared anywhere in this program. That is a fact about the source text, true before a single statement runs.

3
}
4
print 1;

Run this file today and it prints 1 and exits cleanly. Nothing ever told you about line 2.

1 < 0 is never true, so eval never evaluates the then_branch, so it never reaches the NODE_IDENT case for oops, so env_find never gets asked about a name that does not exist. The mistake is real and it is sitting right there in the source, but the compiler only ever finds a mistake by tripping over it while running the program, and this particular trip wire is wired to a branch nobody takes. Change the condition to 1 < 2 and the exact same source file starts crashing with undefined name 'oops'. The program did not get more broken. You just got unlucky enough to run the part that was broken all along.

A real compiler refuses to hand you a binary, or in izvor's case refuses to evaluate anything at all, until it has looked at every line, not just the ones a particular run happens to execute. That is a different kind of walk than eval does, and it is this chapter's whole job.

Checkpoint

Start from a clean tree.

$ cd ~/Developer/izvor $ git status $ make EXTRA_CFLAGS=-Werror test

No code yet. Chapter six's type.c already refuses x + y when the types disagree, at compile time. It would be reasonable to ask why that pass cannot just grow a few more rules instead of a whole new file existing.

type.c answers one question, over and over, about every node in the tree: do these things combine into a sensible type? That question has a clean, local answer for arithmetic and for an annotation. It has no answer at all for the seven questions below, because none of them are about types. They are about whether the program's shape makes sense, and that is what semantic analysis means here: not "is this syntax legal," which the parser already owns, and not "do these types agree," which type.c already owns, but "does this program, as a whole, refer to things that exist and use them the way they were declared."

what sema.c checks7 questions
1
undefined names

Chapter one's oops. A name used where nothing declared it, in the scope it was used in.

2
use before definition

let y = x + 1; let x = 10;. x is declared, just not yet, at the point y needs it. Worth a different message than "does not exist at all."

3
assigning to a let

Chapter five's immutability rule. Caught today only when eval reaches the assignment at run time.

4
calling something that is not a function

There is no such thing as a first-class function value in izvor, so this mostly means: the name exists, as a variable, and you tried to call it anyway.

5
wrong argument count

Chapter seven's eval_call already checks this, but only once the call is actually reached while running.

6
duplicate parameter names

fn f(n: Int, n: Int). Nothing anywhere catches this today. Try it and the second n just shadows the first one, silently, at every call.

7
a non-void function that can finish without returning

Chapter seven's eval_call treats falling off the end of a function as returning 0. That is a real value nobody asked for, standing in for one that was never computed.

Notice how many of these chapter seven already half-handles, at the wrong time. Wrong argument count and assigning to a let both already produce a real diag_error message today, word for word the messages this chapter reuses. What changes is not the wording. What changes is when the message can fire: before eval ever starts, instead of however many statements into a run it takes to reach the mistake.

Checkpoint

The whole pass is reachable through one entry point. It takes the parsed program and the source text, the same two things type_check_program already takes, and it hands back a count instead of setting a flag.

src/sema.h · new file

src/sema.hnew file, 13 lines
1
/* Semantic analysis: name resolution, call checking, and the
   return-path check a type alone cannot answer. Runs after parsing
   and after type checking, before anything is evaluated. */
2
3
#ifndef IZVOR_SEMA_H
4
#define IZVOR_SEMA_H
5
6
typedef struct NodeList NodeList;

A forward declaration, the same move type.h made in chapter six. Enough for a pointer to appear in the signature below without sema.h having to #include "ast.h". sema.c includes ast.h directly, where NodeList is actually defined.

7
8
/* sema_check_program - walk the whole program once, reporting every
   name, call and return-path mistake it finds through diag_error.
   Returns the number of errors found. Zero means the program is
   safe to hand to eval. */

A count instead of a bool, on purpose. type_check_program from chapter six returns nothing and leaves "did it fail" to a separate flag you set yourself. This chapter is the one that has to report several mistakes in one run, so its entry point hands back the number directly, and the driver in step 12 just checks whether it is nonzero.

9
int sema_check_program(const NodeList *program, const char *src);
10
11
#endif

Checkpoint

$ make EXTRA_CFLAGS=-Werror test

This is supposed to fail, the same way type.h did in chapter six: nothing includes it yet and sema.c does not exist, so the Makefile does not know the file is there. That is fine. It exists for the next steps to fill in.

Chapter seven turned env.c into a chain of scopes so a function's locals stop colliding with the caller's. This pass needs the exact same shape, walked at a different time, for a different reason, and it cannot reuse env.c to get it.

env.c's chain holds Bindings: a name and the value sitting in it right now, because the evaluator can only know a value once it has actually run the statement that produced one. This pass runs before anything has run. It never has a value to store, only the fact that a name exists in a given scope, and whether it is allowed to be reassigned. So, the same call chapter six made when it needed a name table that was not about runtime values: build a second, smaller structure that looks like Env but is not it, because bolting a second, unrelated meaning onto Env would make every future reader ask which one a given call is about.

src/sema.c · new file, top of the file

src/sema.cnew file, first 30 lines
1
/* Semantic analysis: a resolve pass over its own scope chain, a
   pre-pass that lets a function call one declared later in the file,
   and a structural check that a non-void function cannot finish
   without returning. */
2
3
#include <stdbool.h>
4
#include <string.h>
5
#include "ast.h"
6
#include "diag.h"
7
#include "sema.h"
8
#include "type.h"

For TypeKind and TYPE_VOID, needed in step 7 to decide whether a function is even required to return anything.

9
10
typedef struct {

The equivalent of chapter seven's Binding, minus the value it has no way to know yet.

11
    const char *name;
12
    int length;
13
    bool mutable;

Needed for rule 3 from step 2: assigning to a name that was declared with let.

14
} SemaName;
15
16
#define MAX_SCOPE_NAMES 64

Smaller than chapter six's 256, on purpose. That table held every name in a whole program with no scoping. This one is per-scope: one function's parameters and locals, or one block's, not the entire file at once.

17
18
typedef struct SemaScope {

The struct names itself, the same way chapter seven's Env does, so parent below can point at another one before the typedef finishes.

19
    struct SemaScope *parent;
20
    SemaName items[MAX_SCOPE_NAMES];
21
    int count;
22
} SemaScope;
23
24
static void scope_init(SemaScope *scope, SemaScope *parent) {
25
    scope->parent = parent;
26
    scope->count = 0;
27
}
28
29
static void scope_define(SemaScope *scope, const char *name, int length, bool mutable) {

Always writes into scope directly, never into scope->parent. That is the one rule that makes an inner name shadow an outer one instead of fighting it for a slot, exactly the property chapter seven's env_define already has.

30
    if (scope->count < MAX_SCOPE_NAMES) {
31
        scope->items[scope->count].name = name;
32
        scope->items[scope->count].length = length;
33
        scope->items[scope->count].mutable = mutable;
34
        scope->count++;
35
    }

Sixty-four names is a lot for one function or one block. Silently refusing to record the sixty-fifth, rather than crashing or growing the array, is the same honest-about-its-limit choice chapter six made for its own table. A real program will never notice.

36
}
37
38
static SemaName *scope_lookup(SemaScope *scope, const char *name, int length) {

The walk chapter seven's env_find does, at compile time, over this pass's own chain instead of the runtime one. Same shape: search this scope, then its parent, then its parent's parent, until something matches or there is nowhere left to climb.

39
    for (SemaScope *s = scope; s != NULL; s = s->parent) {
40
        for (int i = 0; i < s->count; i++) {
41
            if (s->items[i].length == length &&
42
                memcmp(s->items[i].name, name, (size_t)length) == 0) {
43
                return &s->items[i];
44
            }
45
        }
46
    }
47
    return NULL;
48
}

That is the whole difference between this walk and the evaluator's, stated as plainly as I can: eval climbs a chain of scopes that exist because statements before it actually ran and put things in them. This pass climbs a chain of scopes that exist because statements before it, in the text, were read. No program has executed a single instruction by the time this chain has anything in it. Same shape of walk, built for a world where nothing has happened yet.

Now the first half of the function that uses it. resolve_expr walks an expression, reports a name it cannot account for, and hands nothing back, because unlike type.c's check_node this pass has no type to propagate. It only has "did I find a problem," and that lives in one file-scope counter.

src/sema.c · below the scope functions

src/sema.cadd 34 lines, the start of resolve_expr
1
static int error_count;
2
 
3
static void resolve_expr(Node *node, SemaScope *scope,

stmts and index exist for one reason: so an undefined name can tell you it is coming later in this same list, rather than just that it does not exist. Step 2's let y = x + 1; let x = 10; example is exactly what these two parameters are for.

4
                         const NodeList *stmts, int index, const char *src) {
5
    switch (node->type) {

Over NodeType, the same enum check_node switches over, so it carries the same rule: no default:, every one of the sixteen values gets a case, even the ten that can never actually be an expression. The switch cannot tell the difference between "this case is impossible" and "you forgot this case," and that is exactly the point of leaving it out.

6
    case NODE_NUMBER:
7
    case NODE_BOOL:
8
        break;
9
    case NODE_IDENT: {

Rule 1 and rule 2 from step 2 both live here.

10
        if (scope_lookup(scope, node->as.ident.name, node->as.ident.length) != NULL) {
11
            break;
12
        }
13
        error_count++;
14
        if (stmts != NULL &&

The lookahead. declared_later is step 9's job to write; it is used here already, the same forward-reference the parser has used since chapter seven, where parse_factor calls parse_call before either is fully defined.

15
            declared_later(stmts, index + 1, node->as.ident.name, node->as.ident.length)) {
16
            diag_error(src, node->offset, "'%.*s' is used before it is defined",
17
                       node->as.ident.length, node->as.ident.name);
18
        } else {
19
            diag_error(src, node->offset, "'%.*s' is not declared",

The exact wording type.c's lookup_type already uses for the same fact. Reusing it is deliberate: this is the same mistake reported by a different pass, and a reader should not have to learn two spellings for it.

20
                       node->as.ident.length, node->as.ident.name);
21
        }
22
        scope_define(scope, node->as.ident.name, node->as.ident.length, true);

Step 10 explains this line in full. In short: define the broken name anyway, right here, so every later reference to the same typo stops asking about it a second time.

23
        break;
24
    }
25
    case NODE_UNARY:
26
        resolve_expr(node->as.unary.operand, scope, stmts, index, src);
27
        break;
28
    case NODE_BINARY:
29
        resolve_expr(node->as.binary.left, scope, stmts, index, src);
30
        resolve_expr(node->as.binary.right, scope, stmts, index, src);
31
        break;
32
    /* NODE_CALL: step 5.
       NODE_LET .. NODE_RETURN: statements, never reached here. */
33
    }
34
}
Why the ten statement cases are in this switch at all

resolve_expr is only ever called with an expression: a number, a name, a unary or binary operator, or a call. It is never handed a NODE_LET or a NODE_BLOCK. So writing cases for those ten kinds looks like dead weight. It is not. The switch is over NodeType, which describes every node in the language, not just the ones this particular function happens to receive. Leaving a case out because "it can't happen here" is exactly the reasoning that lets a real mistake hide: the day someone adds a seventeenth NodeType in chapter eleven, -Werror lists this switch alongside eval's and check_node's, and you find out here too, instead of only where the new case actually needed real logic.

Checkpoint

$ make test warning: enumeration values not handled in switch: 'NODE_LET', 'NODE_VAR'...

Plain make test, not -Werror, same reason as every earlier chapter's incomplete switch: the warning is correct, and it is not done being wrong yet. declared_later does not exist until step 9 either, so this will not actually link. That is expected too.

Walk the program top to bottom, in one pass, and a call to a function declared three lines further down looks exactly like a call to one that does not exist. Chapter seven's eval_call never has this problem, because it does not walk the tree in order. It searches the whole program for a match every single time it needs one. This pass does not get to cheat that way twice a statement, so it does the search once, up front, and remembers the answer.

why the pre-pass has to existlegal today, at run time
1
fn main_logic() -> int { return helper(); }
2
fn helper() -> int { return 42; }

Declared after its own caller. Chapter seven's eval_call already handles this, because its search covers the entire program list regardless of where the call sits inside it.

3
print main_logic();

Without a pre-pass, a single top-to-bottom walk would check main_logic's body, see a call to helper, and find nothing, because nothing has told this pass helper exists yet. It would report undefined function 'helper' against a program that runs perfectly fine today. That would be a regression this chapter introduced by being stricter than the language actually is. The fix is the same one real compilers use for this exact shape of problem: collect every top-level function declaration into a small table before checking the body of any of them, so by the time any call is checked, every function that will ever exist in this file already does, as far as this pass is concerned.

src/sema.c · below error_count, above resolve_expr

src/sema.cadd 30 lines
1
typedef struct {
2
    const char *name;
3
    int length;
4
    int param_count;

Only the count, not the parameter types. izvor still has no way for two calls to the same function to need different argument types, so the count is the whole signature this pass cares about.

5
} FnSig;
6
 
7
#define MAX_FUNCTIONS 128
8
static FnSig functions[MAX_FUNCTIONS];
9
static int function_count;

File-scope, like chapter six's binding_count, and for the same reason it needs resetting: a second call to sema_check_program in the same process, exactly what a test binary does, would otherwise see functions left over from whichever program ran through it first.

10
 
11
static void collect_functions(const NodeList *program) {

One flat scan of the top level only. A function declared inside a block is not visible to a pre-pass that only looks at program->items, which mirrors the fact that eval_call's own search only ever looks at the top-level program list too.

12
    for (int i = 0; i < program->count; i++) {
13
        Node *stmt = program->items[i];
14
        if (stmt->type != NODE_FN) {
15
            continue;
16
        }
17
        if (function_count < MAX_FUNCTIONS) {

Same honesty-about-limits choice as scope_define. A program with more than 128 top-level functions is not one this stage of izvor needs to worry about yet.

18
            functions[function_count].name = stmt->as.fn.name;
19
            functions[function_count].length = stmt->as.fn.length;
20
            functions[function_count].param_count = stmt->as.fn.params.count;
21
            function_count++;
22
        }
23
    }
24
}
25
 
26
static FnSig *find_function(const char *name, int length) {
27
    for (int i = 0; i < function_count; i++) {
28
        if (functions[i].length == length &&
29
            memcmp(functions[i].name, name, (size_t)length) == 0) {
30
            return &functions[i];
31
        }
32
    }
33
    return NULL;
34
}

Now the case in resolve_expr this table exists to serve: rule 4 and rule 5 from step 2, existence and argument count, in one place, because both questions are answered by the same lookup.

src/sema.c · inside resolve_expr's switch, replacing the NODE_CALL comment placeholder

src/sema.cadd 20 lines
1
    case NODE_CALL: {
2
        FnSig *fn = find_function(node->as.call.name, node->as.call.length);
3
        if (fn == NULL) {
4
            error_count++;
5
            if (scope_lookup(scope, node->as.call.name, node->as.call.length) != NULL) {

Rule 4: the name exists, it is just not a function. This is the one place the two separate namespaces, variables in scope and functions in functions[], both get consulted about the same name, specifically so the error can tell them apart.

6
                diag_error(src, node->offset, "'%.*s' is not a function",
7
                           node->as.call.length, node->as.call.name);
8
            } else {
9
                diag_error(src, node->offset, "undefined function '%.*s'",
10
                           node->as.call.length, node->as.call.name);
11
            }
12
        } else if (fn->param_count != node->as.call.args.count) {

The exact message eval_call already prints at run time, moved to a point before eval ever starts.

13
            error_count++;
14
            diag_error(src, node->offset, "'%.*s' expects %d argument%s, got %d",
15
                       node->as.call.length, node->as.call.name,
16
                       fn->param_count, fn->param_count == 1 ? "" : "s",
17
                       node->as.call.args.count);
18
        }
19
        for (int i = 0; i < node->as.call.args.count; i++) {

Checked unconditionally, even when the call itself was already wrong. Chapter six's binary-operator rule made the same call for the exact same reason: a bad call name should not hide a second, unrelated mistake sitting inside one of its arguments.

20
            resolve_expr(node->as.call.args.items[i], scope, stmts, index, src);
21
        }
22
        break;
23
    }

Checkpoint

$ make test warning: enumeration values not handled in switch: 'NODE_LET', 'NODE_VAR'...

resolve_expr is now complete for every kind of expression the language has. The remaining warning is entirely about the statement kinds step 7 still owes it.

A small, self-contained check, unrelated to the scope chain and unrelated to the function table. fn f(n: Int, n: Int) { ... } parses today, and nothing anywhere objects to it. Try it: the second n silently wins, because chapter seven's eval_call defines each parameter into the same frame in order, and the second env_define for n just adds a second entry that env_find happens to find first.

src/sema.c · above resolve_expr, or below it; it does not depend on the scope chain or the function table

src/sema.cadd 15 lines
1
static void check_duplicate_params(Node *fn, const char *src) {
2
    const NodeList *params = &fn->as.fn.params;

Each entry is a NODE_IDENT, exactly what chapter seven's parse_fn pushes into params, one per parameter, each carrying its own param_type that this check does not need to look at.

3
    for (int i = 0; i < params->count; i++) {
4
        Node *a = params->items[i];
5
        for (int j = 0; j < i; j++) {

Every pair, compared once. A function with a dozen parameters is unusual enough that the O(n²) cost of comparing each one against every earlier one is not worth a smarter structure here.

6
            Node *b = params->items[j];
7
            if (a->as.ident.length == b->as.ident.length &&
8
                memcmp(a->as.ident.name, b->as.ident.name, (size_t)a->as.ident.length) == 0) {
9
                error_count++;

Reported against a's own offset, the second occurrence, because that is the one a reader would not expect to be a problem: the first n looks completely ordinary on its own.

10
                diag_error(src, a->offset, "duplicate parameter '%.*s'",
11
                           a->as.ident.length, a->as.ident.name);
12
                break;
13
            }
14
        }
15
    }
16
}

Nothing calls this yet. Step 7 wires it into the one place a function declaration is actually visited.

Checkpoint

$ make test warning: unused function 'check_duplicate_params'

Expected, same as chapter four's dead is_ident_start before step 5 wired it up.

The last rule from step 2, and the one that needs its own small function, because it is not a question about names at all. It is a question about the shape of the code: can control reach the closing brace of this function without ever hitting a return.

fn double(n: Int) -> Int { if n > 0 { return n * 2; } } parses, type checks, and runs. Call it with -1 and chapter seven's eval_call falls off the end of the body and hands back 0, a value nobody wrote, standing in for a return that never happened. Answering "can this finish without returning" needs no name and no type. It needs to look at the shape of the statements themselves: a block returns if any statement inside it definitely returns; an if only returns for certain if it has an else and both branches return; a while never counts, because this pass has no way to know how many times, if any, a loop body will run.

src/sema.c · above resolve_expr, or anywhere above the point that calls it

src/sema.cadd 24 lines
1
static bool always_returns(Node *stmt) {

Same discipline as type_name in chapter six: every case returns a value, so there is nothing after the switch's closing brace, and a case you forget to add is a build failure instead of a silent wrong answer.

2
    switch (stmt->type) {
3
    case NODE_RETURN:
4
        return true;
5
    case NODE_BLOCK:

One statement guaranteeing a return is enough for the whole block to, even if statements after it would never run. Whether that later code is actually reachable is a different question this chapter does not ask.

6
        for (int i = 0; i < stmt->as.block.stmts.count; i++) {
7
            if (always_returns(stmt->as.block.stmts.items[i])) {
8
                return true;
9
            }
10
        }
11
        return false;
12
    case NODE_IF:

No else_branch means there is always a way through this if that returns nothing, namely the condition being false. && short-circuits, so else_branch is only recursed into once then_branch already came back true, which costs nothing extra to reason about.

13
        if (stmt->as.if_stmt.else_branch == NULL) {
14
            return false;
15
        }
16
        return always_returns(stmt->as.if_stmt.then_branch) &&
17
               always_returns(stmt->as.if_stmt.else_branch);
18
    case NODE_WHILE:

Always false, deliberately conservative. A while true { return 1; } really does always return, but proving that would mean reasoning about whether the condition can ever be false, which this pass does not attempt. It would rather refuse a handful of functions that are secretly fine than accept one that is not.

19
    case NODE_LET:
20
    case NODE_VAR:
21
    case NODE_ASSIGN:
22
    case NODE_PRINT:
23
    case NODE_EXPR_STMT:
24
    case NODE_FN:
25
        return false;

None of these can end a function on their own. A nested fn is its own separate function with its own separate return-path question, checked separately when this same walk reaches it.

26
    case NODE_NUMBER:

This function is only ever called on a statement, so these six can never actually arrive. Written anyway, same reasoning as step 4's trap: the switch does not know that, and it should not have to trust a comment instead of the compiler.

27
    case NODE_IDENT:
28
    case NODE_UNARY:
29
    case NODE_BINARY:
30
    case NODE_BOOL:
31
    case NODE_CALL:
32
        return false;
33
    }
34
}

Now the function that actually walks the tree, resolve_stmt. It is the missing other half of what step 4 started: every statement kind, each doing exactly what its counterpart in eval or check_node does, except that instead of producing a value or a type, it either defines a name, checks one, or recurses.

src/sema.c · below always_returns

src/sema.cadd 47 lines
1
static void resolve_stmt(Node *stmt, SemaScope *scope,
2
                         const NodeList *stmts, int index, const char *src) {
3
    switch (stmt->type) {
4
    case NODE_LET:

Check the initializer before defining the name, in that order, on purpose: let x = x; should see the right-hand x as not-yet-declared, not as referring to the binding it is itself in the middle of creating.

5
    case NODE_VAR:
6
        resolve_expr(stmt->as.binding.value, scope, stmts, index, src);
7
        scope_define(scope, stmt->as.binding.name, stmt->as.binding.length,
8
                     stmt->type == NODE_VAR);
9
        break;
10
    case NODE_ASSIGN: {

Rule 3 from step 2. The exact wording eval's NODE_ASSIGN case already uses, moved earlier.

11
        SemaName *existing = scope_lookup(scope, stmt->as.binding.name, stmt->as.binding.length);
12
        if (existing == NULL) {
13
            error_count++;
14
            diag_error(src, stmt->offset, "'%.*s' is not declared",
15
                       stmt->as.binding.length, stmt->as.binding.name);
16
            scope_define(scope, stmt->as.binding.name, stmt->as.binding.length, true);
17
        } else if (!existing->mutable) {
18
            error_count++;
19
            diag_error(src, stmt->offset, "cannot assign to '%.*s', it is a let binding",
20
                       stmt->as.binding.length, stmt->as.binding.name);
21
        }
22
        resolve_expr(stmt->as.binding.value, scope, stmts, index, src);
23
        break;
24
    }
25
    case NODE_PRINT:
26
    case NODE_EXPR_STMT:
27
        resolve_expr(stmt->as.stmt.value, scope, stmts, index, src);
28
        break;
29
    case NODE_BLOCK: {

A fresh scope, parented to whatever scope this block sits inside of, exactly like eval's own NODE_BLOCK case builds a fresh Env the same way, one plain local variable, no allocation.

30
        SemaScope inner;
31
        scope_init(&inner, scope);
32
        for (int i = 0; i < stmt->as.block.stmts.count; i++) {
33
            resolve_stmt(stmt->as.block.stmts.items[i], &inner, &stmt->as.block.stmts, i, src);
34
        }
35
        break;
36
    }
37
    case NODE_IF:
38
        resolve_expr(stmt->as.if_stmt.cond, scope, stmts, index, src);
39
        resolve_stmt(stmt->as.if_stmt.then_branch, scope, stmts, index, src);
40
        if (stmt->as.if_stmt.else_branch != NULL) {
41
            resolve_stmt(stmt->as.if_stmt.else_branch, scope, stmts, index, src);
42
        }
43
        break;
44
    case NODE_WHILE:
45
        resolve_expr(stmt->as.while_stmt.cond, scope, stmts, index, src);
46
        resolve_stmt(stmt->as.while_stmt.body, scope, stmts, index, src);
47
        break;
48
    case NODE_FN: {

Rules 6 and 7 from step 2, plus the one piece of setup they both needed: a scope that starts with the parameters already in it.

49
        check_duplicate_params(stmt, src);
50
        if (stmt->as.fn.ret_type != TYPE_VOID && !always_returns(stmt->as.fn.body)) {
51
            error_count++;
52
            diag_error(src, stmt->offset, "'%.*s' can finish without returning a value",
53
                       stmt->as.fn.length, stmt->as.fn.name);
54
        }
55
 
56
        SemaScope frame;

Parented to global_scope, not to scope, the same choice chapter seven's eval_call makes when it calls env_init(&frame, global_env(env)) instead of parenting to the caller. A function's meaning depends on what is written next to it, never on where it happened to be called from, so its body has to resolve names against the top level, not against whatever block its own declaration is sitting in.

57
        scope_init(&frame, global_scope);
58
        for (int i = 0; i < stmt->as.fn.params.count; i++) {
59
            Node *param = stmt->as.fn.params.items[i];
60
            scope_define(&frame, param->as.ident.name, param->as.ident.length, false);

false: a parameter cannot be reassigned, the same rule chapter seven's eval_call enforces by calling env_define(&frame, ..., false) for every parameter it binds.

61
        }
62
        resolve_stmt(stmt->as.fn.body, &frame, NULL, 0, src);

stmt->as.fn.body is always a NODE_BLOCK, guaranteed by parse_fn calling parse_block for it. Its own NODE_BLOCK case above will give it a further, nested scope parented to frame, exactly the same layering the runtime has between a call frame and the block inside it.

63
        break;
64
    }
65
    case NODE_RETURN:
66
        if (stmt->as.return_stmt.value != NULL) {
67
            resolve_expr(stmt->as.return_stmt.value, scope, stmts, index, src);
68
        }
69
        break;
70
    case NODE_NUMBER:
71
    case NODE_IDENT:
72
    case NODE_UNARY:
73
    case NODE_BINARY:
74
    case NODE_BOOL:
75
    case NODE_CALL:
76
        break;

Expressions never arrive as a bare statement on their own; NODE_EXPR_STMT above is what wraps one when that happens. Listed anyway, same exhaustiveness rule as everywhere else in this file.

77
    }
78
}

One name is still undefined: global_scope. It has to point at the one scope every function body resolves against, which means it has to be set before resolve_stmt ever runs, by the function that owns the whole walk from end to end.

src/sema.c · above resolve_expr, near error_count

src/sema.cadd 1 line
1
static SemaScope *global_scope;

src/sema.c · at the bottom of the file

src/sema.cadd 12 lines, sema_check_program
1
int sema_check_program(const NodeList *program, const char *src) {
2
    error_count = 0;

Every file-scope static reset here, the same discipline as chapter six's type_check_program: compiling a program is a fresh start, and a second call in the same process must not see anything left behind by an earlier one.

3
    function_count = 0;
4
 
5
    SemaScope root;
6
    scope_init(&root, NULL);
7
    global_scope = &root;
8
 
9
    collect_functions(program);

The pre-pass, run to completion, before a single call is checked.

10
 
11
    for (int i = 0; i < program->count; i++) {
12
        resolve_stmt(program->items[i], &root, program, i, src);
13
    }
14
 
15
    return error_count;
16
}

Checkpoint

$ make EXTRA_CFLAGS=-Werror test

declared_later still does not exist, so this will not link yet; that is step 9. Every switch in sema.c is otherwise complete and clean under -Werror on its own.

Everything above finds several sema mistakes in one run for free, because resolve_stmt never stops walking just because one statement had a problem. The parser is not that lucky. Go look at chapter five's parser_parse: the moment parse_statement returns NULL, it frees everything parsed so far and gives up on the whole file. A source file with three missing semicolons in it gets you exactly one error message, for the first one, and the other two are invisible until you fix the first and run the compiler again.

The standard fix is called panic mode recovery, and the idea is one sentence: when a statement fails to parse, do not stop, skip forward until you are somewhere the parser can plausibly make sense of again, and keep going. In izvor, "somewhere that makes sense again" has an obvious answer, because chapter five made semicolons mandatory. The token right after a semicolon is always the start of a fresh statement, no matter how confused the parser was about the statement before it. That is the whole reason a required terminator earns its keep here: it gives recovery a landmark to aim for that a newline-terminated or terminator-free grammar would not have.

src/parser.c · above parse_statement, or anywhere above parser_parse

src/parser.cadd 12 lines
1
static bool starts_statement(TokenType type) {

Every token that a legal statement can begin with, read straight off the grammar chapters five and seven already wrote: let, var, print, if, while, fn, return, and { for a bare block.

2
    return type == TOK_LET || type == TOK_VAR || type == TOK_PRINT ||
3
           type == TOK_IF || type == TOK_WHILE || type == TOK_FN ||
4
           type == TOK_RETURN || type == TOK_LBRACE;
5
}
6
 
7
/* synchronize - after a bad statement, skip tokens until parsing can
   plausibly resume: just past a semicolon, since that almost always
   ends whatever went wrong, or at a token that starts a fresh
   statement even without one. Stops at EOF too, so a file that never
   recovers cannot make this loop spin forever. */
8
static void synchronize(Parser *p) {

No lookbehind needed. Rather than ask "was the token I just consumed a semicolon," which would need a previous field this Parser has never had, this checks p->current for a semicolon before consuming it, then advances past it either way.

9
    while (!parser_check(p, TOK_EOF)) {
10
        bool at_semicolon = parser_check(p, TOK_SEMICOLON);
11
        parser_advance(p);
12
        if (at_semicolon || starts_statement(p->current.type)) {

Stop either because the semicolon that ended the mess is now behind us, or because, semicolon or not, the very next token already looks like the start of something the parser knows how to read.

13
            return;
14
        }
15
    }
16
}
Who frees the partial program now

Chapter five's parser_parse called nodelist_free(out) the moment any statement failed, which meant every caller could treat "parse failed" and "there is nothing in out to clean up" as the same fact. That stops being true the moment recovery exists. The whole point of synchronizing is to keep the statements that did parse successfully, in the same list, so more errors can be found in the same run, so parser_parse can no longer free out on the first failure without throwing away exactly the information recovery exists to collect. Step 9 rewrites parser_parse with that in mind, and once it does, main.c has a new responsibility it did not have before: on a failed parse, it now has to call nodelist_free on the program itself, because the function that used to do that for it no longer can.

Checkpoint

$ make test warning: unused function 'synchronize'

Two small pieces left before either pass can actually run to completion: the parser has to use synchronize instead of giving up, and resolve_expr is still calling a function, declared_later, that step 4 promised but never wrote.

src/parser.h · inside struct Parser, alongside fn_depth

src/parser.hadd 1 line
1
    int error_count;

A second counter, next to fn_depth, for the same reason chapter seven added that one: a fact parser_parse needs to track across the whole file, not just within one call to parse_statement.

src/parser.c · parser_init, alongside p->fn_depth = 0;

src/parser.cadd 1 line
1
    p->error_count = 0;

src/parser.c · replacing the existing parser_parse from chapter five

src/parser.crewrite the loop body
1
int parser_parse(Parser *p, NodeList *out) {
2
    nodelist_init(out);
3
    p->error_count = 0;
4
    while (!parser_check(p, TOK_EOF)) {
5
        Node *stmt = parse_statement(p);
6
        if (stmt == NULL) {

This used to free out and return immediately. Now it counts the failure, resynchronizes, and loops back around to try the next statement, so parse_statement gets called again instead of the whole function bailing.

7
            p->error_count++;
8
            synchronize(p);
9
            continue;
10
        }
11
        if (!nodelist_push(out, stmt)) {
12
            ast_free(stmt);
13
            nodelist_free(out);
14
            return 0;
15
        }
16
    }
17
    return p->error_count == 0;

Success still means exactly what it meant before: a caller checking if (!parser_parse(...)) does not need to change at all. What changed is everything inside the call: a file with three bad statements now runs parse_statement three separate times against three separate parts of the file, instead of stopping after the first.

18
}

Reporting in source order needs no extra work at all. Every message, from either pass, fires the moment its own walk reaches the node responsible, and both walks move forward through the file and never backward. Trace a file with a bad statement on line 2, an undefined name on line 5, and a wrong argument count on line 9: parser_parse hits line 2 first because it is scanning top to bottom, reports it, resynchronizes, and reaches lines 5 and 9 as ordinary, parseable statements. Assuming the parse as a whole still fails only because of line 2's error count, main would stop there in practice; but if line 2 had instead been something sema catches instead of the parser, sema_check_program's single top-to-bottom loop reaches line 5 before line 9 for the same reason: it is walking the array in order, and it never jumps ahead or back to reconsider anything.

Now the lookahead resolve_expr has been calling since step 4.

src/sema.c · above resolve_expr

src/sema.cadd 11 lines
1
/* declared_later - true if stmts, starting at from, contains a let
   or var that would define name. Used only to choose the clearer of
   two messages: a name arriving later in this same block was written
   in the wrong order, not misspelled and not missing entirely. */
2
static bool declared_later(const NodeList *stmts, int from,
3
                          const char *name, int length) {
4
    for (int i = from; i < stmts->count; i++) {
5
        Node *stmt = stmts->items[i];
6
        if ((stmt->type == NODE_LET || stmt->type == NODE_VAR) &&
7
            stmt->as.binding.length == length &&
8
            memcmp(stmt->as.binding.name, name, (size_t)length) == 0) {
9
            return true;
10
        }
11
    }
12
    return false;
13
}

It only looks at the exact list it is handed, one level of nesting at a time, never into a nested block. let y = x + 1; if 1 > 0 { let x = 10; } should still say x is not declared, not that it is used early, because the x inside the if lives in a different, inner scope that the outer y was never going to see regardless of order.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

First clean, fully linked build since step 3. Try a file with three separate missing semicolons in it by hand and count the error lines on stderr.

No new code in this step. Two lines already written, back in step 4, deserve a second look, because without them, everything this chapter just built would make izvor's error output worse, not better.

Panic mode recovery has a well-known failure of its own: resynchronizing after one mistake can leave the parser in a slightly wrong position, and the very next statement, which was actually fine, gets misread and reported as a second error that only exists because of the first one. Chapter eight's synchronize keeps this small by aiming at the most reliable landmark the grammar has, the token right after a semicolon, which is also exactly where a real statement is guaranteed to start. It cannot eliminate every possible avalanche, but it gives the parser the best-anchored place to resume that this grammar offers.

Sema has the exact same problem in a different shape, and chapter six already solved it once. Go back to resolve_expr's NODE_IDENT case:

from step 4, look againone line does the real work
1
error_count++;
2
diag_error(src, node->offset, "'%.*s' is not declared", ...);
3
scope_define(scope, node->as.ident.name, node->as.ident.length, true);

Defining the name immediately after reporting it missing.

Picture print z; print z; print z;, three references to the same never-declared name. Without that third line, every single one of those three would independently fail the same scope_lookup and independently report the exact same fact about the exact same name. One root cause, three identical messages, and a reader left wondering whether they are looking at three bugs or one. The line above poisons the name into the current scope the moment it is first reported: the second and third print z; now find z sitting right there in scope, scope_lookup succeeds, and nothing fires a second time.

This is TYPE_ERROR from chapter six, wearing a different hat. Chapter six's checker hands back a sentinel type meaning "already told you, do not tell you again" so one bad addition does not cascade into every later expression that touches its result. This pass has no type to hand back; it has a scope to write into instead. Same rule, same reason, different mechanism: the first time a mistake is found, immediately do whatever makes this exact mistake invisible to the rest of the walk, so what should be one message never turns into ten. Every new check this pass or a later one adds has to ask itself the same question chapter six's step 9 already asked: after I report this, what do I do to stop myself from reporting it again on the next line.

Why NODE_ASSIGN pays the same cost twice

Look back at step 7's NODE_ASSIGN case: an assignment to an undeclared name also calls scope_define after reporting it, for the identical reason. x = 1; x = 2; x = 3; against a never-declared x reports once, not three times, because the first bad assignment defines x well enough for the second and third to pass the lookup cleanly. It is defined as mutable there on purpose, even though the error is unrelated to mutability, so a poisoned name never goes on to trigger a second, unrelated "it is a let binding" message about a binding that was never real in the first place.

Checkpoint

One test file, written by you, and it is the most important one in this chapter. Every earlier golden test in this project has pinned down what a single mistake says. This one proves the chapter's actual promise: that a broken file does not make you fix mistakes one at a time, rerunning the compiler after each one.

tests/test_sema_1.c · new file · copy the shape of tests/test_type_1.c

Assertions to write, cheapest and most specific to most integrated.

assertions to write6 cases
1
"print z;"

Refused with 'z' is not declared, and sema_check_program returns 1. The plain undefined-name case, proving rule 1.

2
"let y = x + 1; let x = 10;"

Refused with 'x' is used before it is defined, not the plainer "not declared" message. Proves declared_later actually runs, not just that some error fires.

3
"let n = 5; n = 6;"

Refused with cannot assign to 'n', it is a let binding. Then "var n = 5; n = 6;", which must be accepted: the happy case for rule 3, so a later regression that broke mutability entirely would fail here too.

4
"fn add(a: Int, a: Int) -> Int { return a; }"

Refused with duplicate parameter 'a'.

5
"fn maybe(n: Int) -> Int { if n > 0 { return n; } }"

Refused with 'maybe' can finish without returning a value. Then the same function with an else { return 0; } added, which must be accepted.

6
"print z; print z;"

Redirect stderr to a buffer and count lines starting with error:. Exactly one, not two. This is step 10's poisoning guard, and it is the assertion most likely to quietly break if a later change touches scope_define's placement.

Now the one that matters most, and the reason this chapter exists.

tests/golden/three-mistakes.iz · new fixture, with a matching tests/golden/three-mistakes.expected

tests/golden/three-mistakes.izone file, three unrelated bugs
1
let a = 1
2
let b = 2;

Line 1 is missing its semicolon. That is a parser mistake, and it is the one synchronize exists for: without recovery, this single missing character used to make everything below it unreachable.

3
print unknown_name;

A sema mistake: an undefined name, entirely unrelated to line 1.

4
fn broken(n: int) -> int { print n; }

A second, different sema mistake: broken promises an Int and can fall off the end without returning one.

Run this through the driver you finish in step 12 and stderr should hold three separate error: blocks, in this order: a parse error naming the missing semicolon on line 1, then 'unknown_name' is not declared for line 3, then 'broken' can finish without returning a value for line 4. Line 2 never gets mentioned at all, which is exactly right: it has nothing wrong with it, and the fact that it sits one line below a syntax error is no longer enough to make it collateral damage.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test golden: 13 cases match all unit tests passed

Every earlier golden case still matches, and the three-mistake fixture reports all three, in order, and nothing else.

Two small edits close the chapter: a call in the driver, and a line in the Makefile so the new file actually gets compiled and linked into everything.

src/main.c · the failed-parse check, right where chapter five's driver already handles parser_parse returning 0

src/main.c1 line added to an existing check
1
    if (!parser_parse(&parser, &program)) {

Substitute your driver's own variable names here; the shape is what matters.

2
        nodelist_free(&program);

The line step 8's trap called out. parser_parse no longer frees the partial program on failure, since it needs to survive long enough for recovery to keep collecting more errors, so the caller now owns that cleanup, the same way it already owns cleanup after every other failure point in main.

3
        return 1;
4
    }

src/main.c · in main(), directly after chapter six's type_check_program call and its own failure check, before the loop that calls eval

src/main.cadd 1 include, roughly 4 lines
1
#include "sema.h"
2
 
3
    if (sema_check_program(&program, src) > 0) {

Placed after type checking and before evaluation, exactly where the task this pass does belongs: types have already been confirmed sound, nothing has been evaluated yet, and sema gets the final say before anything runs. Every error it found is already on stderr by the time this line runs, printed as it was found rather than collected and dumped at the end.

4
        nodelist_free(&program);
5
        return 1;
6
    }

Makefile · the SRC := line

Makefile1 line changed
1
SRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c src/type.c
2
SRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c src/type.c src/sema.c

Leave it off this list and every test that touches sema_check_program or any of its static helpers fails to link, naming them as undefined symbols. The same linker-problem-dressed-as-a-compiler-problem chapter six's step 3 already walked through once.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test golden: 13 cases match all unit tests passed $ make && ./build/izvor tests/golden/three-mistakes.iz

Three error: blocks on stderr, none of them about line 2, and a nonzero exit status. A program with none of these seven mistakes in it still compiles and runs exactly as it did at the end of chapter seven; this pass has nothing to say about a program that was already correct.