No code in this step. Before you write a type checker you need to be able to say, out loud, what problem it solves that the parser does not already solve.
A type is a promise about the shape of a value. 10 is an Int,
which promises you can add it, subtract it, multiply it. true is a
Bool, which promises exactly the opposite: nothing about it is a quantity.
The parser has already checked that x + y is grammatically a sound
expression, an identifier, a plus sign, another identifier. It has no way to check
whether adding those two particular things means anything, because the parser works one
token at a time and never asks what a name actually holds.
Take this program.
let x = 10;let y = true;print x + y;Three legal statements in a row, and the third one asks the machine to add a number to a truth value. Nothing about the grammar objects.
Right now that program parses without a complaint, gets handed to the evaluator, and
the evaluator does whatever adding a long to whatever a boolean happens to
be represented as does. That is not a crash you can rely on. It is a wrong answer that
looks like a right one, and those are the expensive kind, because nothing tells you it
happened.
So a new pass runs after parsing and before evaluation: walk the finished tree once, work out the type of every piece of it, and if two pieces get combined in a way that does not make sense, stop and say so. Nothing after this pass has to worry about types again. The evaluator can go on assuming every addition really is two numbers, because by the time it runs, that has already been checked.
You could imagine folding this into the grammar somehow, but a grammar describes
shape, not meaning. x + y has exactly one shape no matter what
x and y turn out to be. Whether that shape is sound depends
on facts the parser never collects, namely what each name was declared as, several
lines earlier. That is a different kind of question, which is why it gets a different
pass.
Checkpoint
A new file, a new header, and one enum. izvor gets exactly four types for now.
src/type.h · new file
/* Types: the four shapes an izvor value can have, and the sentinel
the checker uses to stop one mistake from becoming ten. */#ifndef IZVOR_TYPE_H#define IZVOR_TYPE_Htypedef enum {Four cases today. Chapter 7's TYPE_FN is not here yet, on purpose. Adding it now with nothing that produces it would just be a dead case waiting for a compiler warning.
TYPE_INT,What 10 is, and what + - * / require on both sides.
TYPE_BOOL,What true and false are. This chapter adds them as a second kind of value specifically so there is a second type to get wrong.
TYPE_VOID,The type of a statement that produces no value: let, var, an assignment, print. Nothing downstream can use a let statement as an expression, and giving statements a real type instead of skipping them keeps the checking function returning one honest answer for every node, with no special case for "this kind has no type".
TYPE_ERRORNot a real type. A sentinel meaning "already reported, do not report again". Step 9 is entirely about this one value.
} TypeKind;const char *type_name(TypeKind type);Declared here, written in step 3.
#endif
TYPE_ERROR is the one that is not obvious. Say the checker only had three
types and no sentinel. It sees x + y where x is already wrong
for some earlier reason, reports that x's addition does not type check, and
has to pick some type for the whole expression anyway so the pass can keep going. Pick
TYPE_INT and the next line that uses the result wrongly also gets reported,
for a mistake that is really the same mistake. One typo at the top of a file would print
ten error messages by the bottom of it. TYPE_ERROR is a type that means "I
already told you about this one", and every rule from here on checks for it first.
Checkpoint
This is supposed to fail. Nothing includes type.h yet
and nothing builds type.c, so the Makefile does not even know the file
exists. That is fine. It exists for the next step to fill in.
Same discipline as token_type_name back in chapter 4: a switch with no
default:, so forgetting a case is a build failure and not a silent gap.
src/type.c · new file, plus one line in the Makefile
/* Type checking: a bottom-up pass that assigns every node a TypeKind
and refuses a program whose types do not agree. */#include <string.h>For memcmp, used twice later in this file: once to look up a name's type, once to read a type annotation like Int off the source text.
#include "ast.h"#include "diag.h"#include "type.h"const char *type_name(TypeKind type) {Somebody reads this string in an error like "found Bool", so it names the type the way the source spells it, capitalized, not the way the enum spells it.
switch (type) { case TYPE_INT: return "Int"; case TYPE_BOOL: return "Bool"; case TYPE_VOID: return "Void"; case TYPE_ERROR: return "<error>";This one should never actually reach a person, since a node already carrying TYPE_ERROR is not supposed to trigger a fresh message. Angle brackets mark it as a value that leaked somewhere it should not have, the same way <input> marks a path that was never set.
}
No return "???"; after the switch this time, unlike token_type_name.
TokenType there had to handle a value read straight off untrusted bytes in a
file, so a fallback was honest. TypeKind only ever holds one of the four
values this same file hands out, so if the switch does not cover every case, the
function should fail to compile rather than quietly return nothing. Close the function
with nothing after the switch and let the missing-return warning, promoted to an error
under -Werror, be the second layer of protection under the missing-case
warning.
Makefile · the SRC := line
SRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.cSRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c src/type.cEvery test binary links against this list. Leave type.c off it and every test that touches the checker fails to link, with an error naming type_check_program or type_name as undefined. That is a linker problem wearing a compiler problem's clothes, worth recognizing on sight.
Checkpoint
Clean build. type.c compiles and links, and nothing calls it yet.
A type checker with only one type has nothing to disagree about. true and
false are new tokens, a new keyword check in the lexer, and a new leaf kind
in the AST.
src/lexer.h · inside the typedef enum, before TOK_EOF
TOK_VAR, TOK_TRUE,Same spot every earlier keyword landed in: right before the pair that means "not a real piece of syntax".
TOK_FALSE, TOK_EOF,src/lexer.c · inside token_type_name, before the TOK_EOF case · and inside lexer_next's identifier branch, alongside the existing word_is checks
case TOK_TRUE: return "TRUE"; case TOK_FALSE: return "FALSE"; if (word_is(lx, start, "true")) return make_token(lx, TOK_TRUE, start);Same shape as the let, var, print checks chapter 4 already wrote. Maximal munch already handles the trap here for free: truest eats all six characters first, fails word_is on length, and comes back as an ordinary IDENT.
if (word_is(lx, start, "false")) return make_token(lx, TOK_FALSE, start);src/ast.h · at the top, and inside the typedef enum for NodeType
#include "type.h"Placed with the other #include, above the NodeType enum. Node is about to carry a TypeKind field in step 6, so ast.h needs to know what that name means. type.h does not include ast.h back: it has no reason to know what a Node is, and a header including its own includer is how you get a cycle.
NODE_BINARY, NODE_BOOL,A leaf, like NODE_NUMBER. It carries its value directly and has no children to recurse into.
src/ast.h · inside the union as, next to long number;
long number; bool boolean;A plain scalar sitting in the union next to number, the same way number sits there for NODE_NUMBER. Needs #include <stdbool.h> added at the top of ast.h, since nothing there has needed bool until now.
src/ast.h and src/ast.c · alongside ast_number
Node *ast_number(long value);Node *ast_bool(bool value);No offset parameter. Chapter 5 deliberately kept positions out of the constructors and added the at wrapper in parser.c instead, which stamps the offset on whatever it is handed and passes it straight through. Give the new one the same shape as the old one, and let at do the positioning.
Node *ast_bool(bool value) { Node *node = new_node(NODE_BOOL); if (node == NULL) { return NULL; } node->offset = offset; node->as.boolean = value; return node;}src/ast.c · inside ast_free's switch, next to NODE_NUMBER
case NODE_NUMBER: case NODE_BOOL:Both leaves, both nothing to recurse into, so they share one break. Falling one case into another on purpose, with no code between them, is the one time a missing break is the point rather than a bug.
break;Checkpoint
This one fails too, and it should fail loudly: the parser has never
heard of TOK_TRUE or NODE_BOOL, so nothing calls
ast_bool yet and clang warns that it is dead. Step 5 is what wires it up.
Two more branches in parse_factor, the same shape as the number check right
above them.
src/parser.c · inside parse_factor, directly below the TOK_NUMBER check, above the TOK_LPAREN check
if (parser_check(p, TOK_NUMBER)) { /* ...unchanged... */ } if (parser_check(p, TOK_TRUE)) {parser_check rather than parser_match here because the offset the node needs to record is the token's own start, and that is easiest to read off p->current before advancing past it.
long offset = p->current.start - p->lexer.src; parser_advance(p); return at(ast_bool(true), offset); } if (parser_check(p, TOK_FALSE)) { long offset = p->current.start - p->lexer.src; parser_advance(p); return at(ast_bool(false), offset); }
Notice what is not here: no case in parse_term, no case in
parse_expression. A boolean is a factor, exactly like a number or a
parenthesized group, so once parse_factor can produce one, 1 + true
already parses. It should. Whether that particular addition means anything is not the
parser's question. That is next chapter's, sorry, this chapter's whole point, and it is
exactly why the checking pass has to exist as a separate step instead of being folded in
here.
Checkpoint
izvor -e "true" now parses. It still evaluates and prints garbage if you
run it, because eval has never heard of NODE_BOOL and the
type checker that will refuse to reach that point does not exist until step 7. That
gap is expected and closes by the end of this chapter.
The checker needs somewhere to write down what it worked out, so a later step of the same walk, and later passes entirely, can read the answer instead of recomputing it.
src/ast.h · in struct Node, directly below the offset field chapter 5 added, above the union as
struct Node { NodeType type; long offset; TypeKind vtype;Not called type. Keep reading.
union { ... } as;};
If you went looking for a field to hold this literally called type, you
would have run into a wall: Node already has one, NodeType type;,
sitting there since chapter 1. C does not allow two members of the same struct to
share a name, so TypeKind type; right under it does not give you a new
field. It gives you a compile error, and not a helpful one. Depending on your compiler you get
something like duplicate member 'type' pointing at the second
declaration, which is at least honest about what happened.
There is a real distinction being papered over if you try to force one name onto both.
node->type answers "what kind of syntax is this", fixed the moment the
parser built the node and never touched again. node->vtype answers
"what kind of value does this produce", unknown until this pass runs and possibly
different for the same NodeType in two different programs, since a
NODE_IDENT reading an Int variable and one reading a
Bool variable are the same syntax tag with two different answers. Two
questions, two fields, two names.
Every constructor that already exists, ast_number, ast_unary,
ast_binary, and the ones chapter 5 added for statements, leaves
vtype untouched. That is fine. Nothing reads it until the checking pass in
the next step runs and fills it in, node by node, on its way through the tree.
Checkpoint
One function, check_node, shaped exactly like ast_free and
eval already are: a switch over node->type, no
default:, recurse into children before deciding anything about the parent.
This step writes the leaves. Step 8 writes the two cases that actually combine things.
src/type.c · below the #include block from step 3
First, a place to remember what each name was declared as. Chapter 5's environment
stores a name's runtime value for the evaluator. This pass needs a name's
type, which is a different fact, known earlier, and thrown away once the
program runs. Rather than teach env.c a second job, this file keeps its own
small table, and makes the exact same call chapter 5 made for the evaluator's
environment: one flat array, linear lookup, capacity 256. A hash table is still not
worth it. A real program has dozens of names in scope, not thousands.
typedef struct { const char *name; int length; TypeKind vtype;} TypeBinding;#define MAX_TYPE_BINDINGS 256static TypeBinding bindings[MAX_TYPE_BINDINGS];static int binding_count = 0;File-scope static, so this table lives for as long as the process does, not just for one call. Step 11 comes back to why that needs one line of care.
static void record_type(const char *name, int length, TypeKind vtype) {Called once per let or var, after its initializer has already been checked.
for (int i = 0; i < binding_count; i++) { if (bindings[i].length == length && memcmp(bindings[i].name, name, (size_t)length) == 0) {Length before bytes, same trap as chapter 4's word_is. A name is a pointer plus a length with no terminating zero, so memcmp alone, without checking the lengths match first, would say total and total_count agree on their first six characters and call that a match.
bindings[i].vtype = vtype; return; } } if (binding_count < MAX_TYPE_BINDINGS) {Re-declaring a name inside the same scope is chapter 8's error to catch, once sema.c exists to own that rule. For now, a second let x just overwrites the first entry above, and a program that runs out of the 256 slots silently stops recording new ones rather than crashing. Neither is a real answer. Both are honest about not being sema.c's job yet.
bindings[binding_count].name = name; bindings[binding_count].length = length; bindings[binding_count].vtype = vtype; binding_count++; }}static TypeKind lookup_type(const char *name, int length,Takes src and offset too, because an undeclared name is itself an error this function has to report, not just a value it fails to find.
const char *src, long offset) { for (int i = 0; i < binding_count; i++) { if (bindings[i].length == length && memcmp(bindings[i].name, name, (size_t)length) == 0) { return bindings[i].vtype; } } diag_error(src, offset, "'%.*s' is not declared", length, name);%.*s prints exactly length bytes starting at name, which is the only correct way to print a name that is a slice of the source rather than its own zero-terminated string.
return TYPE_ERROR;}Now the walk itself, starting with the three leaf cases: a node with no children, whose type is either fixed by what kind of node it is, or found by asking the table above.
static TypeKind check_node(Node *node, const char *src) { TypeKind result; switch (node->type) { case NODE_NUMBER:A number literal is always an Int. Nothing to check, nothing to recurse into.
result = TYPE_INT; break; case NODE_BOOL:Same idea. It does not matter whether the value underneath is true or false, only that a NODE_BOOL is a Bool.
result = TYPE_BOOL; break; case NODE_IDENT:The only case so far that is not a straight leaf answer: it asks the table what this name was last recorded as.
result = lookup_type(node->as.ident.name, node->as.ident.length, src, node->offset); break; /* NODE_UNARY, NODE_BINARY: step 8.
NODE_LET, NODE_VAR, NODE_ASSIGN, NODE_PRINT, NODE_EXPR_STMT: step 9. */
The function is not finished, on purpose, and will not compile clean yet: a switch over
NodeType with only three of the ten cases present under
-Werror is exactly the incomplete-switch warning doing its job. Step 8 adds
the two that actually combine two types into a decision, which is the interesting part
of this whole chapter.
One more piece before that: the function a caller actually reaches for. It resets the table, then checks every top-level statement in order.
void type_check_program(const NodeList *program, const char *src) { binding_count = 0;Compiling a program is a fresh start. Without this line, a second call in the same process, which is exactly what a test binary running several cases does, would see names left behind by the previous call and either resolve against the wrong binding or silently pass a lookup that should have failed. One assignment, and every call behaves like the first one.
for (int i = 0; i < program->count; i++) { check_node(program->items[i], src); }}src/type.h · add the declaration, above the closing #endif
typedef struct NodeList NodeList;A forward declaration: enough for a pointer to NodeList to appear in a function signature, without type.h having to #include "ast.h" and reverse the one-directional dependency step 4 set up. type.c itself already includes ast.h directly, where the real definition is needed.
void type_check_program(const NodeList *program, const char *src);Checkpoint
Plain make test, not -Werror, same reason as chapter 4 step
3: the warning is correct and expected, and it goes away over the next two steps as
the rest of the cases get filled in.
NODE_UNARY only ever means - at this point in the language, and
NODE_BINARY only ever means + - * /. Both rules say the same
thing: every side has to be an Int, or the checker names what it actually
found and hands back TYPE_ERROR.
src/type.c · inside check_node's switch, where the comment placeholder is
case NODE_UNARY: {Braces around a case body that declares its own local variables. Without them, operand here would be visible to every case below it in the same switch, which is legal C and a source of exactly the kind of bug that looks fine until it does not.
TypeKind operand = check_node(node->as.unary.operand, src); if (operand == TYPE_ERROR) {The propagation guard. Checked first, before anything else about this node. Step 9 is entirely about why.
result = TYPE_ERROR; } else if (operand == TYPE_INT) { result = TYPE_INT; } else { diag_error(src, node->offset,Reported against node->offset, the minus sign's own position, which is why chapter 5 made every node record where it came from in the first place. Without it this error would have nowhere honest to point.
"'-' needs Int, found %s", type_name(operand)); result = TYPE_ERROR; } break; } case NODE_BINARY: { TypeKind left = check_node(node->as.binary.left, src); TypeKind right = check_node(node->as.binary.right, src);Both sides get checked unconditionally, even if left already came back TYPE_ERROR. Skipping right once left fails would also skip reporting a real, separate mistake sitting on the right side, and short-circuiting error reporting is not the kind of short-circuiting this pass is for.
if (left == TYPE_ERROR || right == TYPE_ERROR) { result = TYPE_ERROR; } else if (left == TYPE_INT && right == TYPE_INT) { result = TYPE_INT; } else { diag_error(src, node->offset,Names both sides, even though usually only one of them is the culprit. Saying "found Int and Bool" costs nothing extra to write and saves the reader a second look back at the line to work out which side was the problem.
"arithmetic needs Int on both sides, found %s and %s", type_name(left), type_name(right)); result = TYPE_ERROR; } break; }
This is the rule that refuses this chapter's opening example. x + y checks
x, gets TYPE_INT back from the table, checks y,
gets TYPE_BOOL, and neither side is TYPE_ERROR so the guard on
line 17 does not fire. Left is Int, right is not, so line 19's condition
fails too, and the program is refused with arithmetic needs Int on both sides,
found Int and Bool, pointing at the +.
Checkpoint
Five cases down, five to go. Still plain make test for one more step.
The five statement cases left. None of them introduce a new idea. Every one of them
checks its inner expression, and every one of them produces TYPE_VOID,
because a statement is not a value.
src/type.c · inside check_node's switch, filling out the remaining cases
case NODE_LET:Falling through into NODE_VAR below on purpose. Whether the binding is mutable is a fact the evaluator cares about, stored in chapter 5's Binding. It is not a fact the type of the initializer depends on, so one case handles both.
case NODE_VAR: { TypeKind value_type = check_node(node->as.binding.value, src); record_type(node->as.binding.name, node->as.binding.length, value_type); result = TYPE_VOID; break; } case NODE_ASSIGN: {Whether the target is even allowed to be reassigned, the immutability rule locked in chapter 5, is checked elsewhere, against the mutable flag on the evaluator's Binding. This case only asks a type question: does the new value match what the name already is.
TypeKind existing = lookup_type(node->as.binding.name, node->as.binding.length, src, node->offset); TypeKind value = check_node(node->as.binding.value, src); if (existing != TYPE_ERROR && value != TYPE_ERROR && existing != value) { diag_error(src, node->offset, "'%.*s' is %s, cannot assign %s", node->as.binding.length, node->as.binding.name, type_name(existing), type_name(value)); } result = TYPE_VOID; break; } case NODE_PRINT:The return value of check_node is thrown away here on purpose. print accepts any type, an Int or a Bool both print fine, so there is nothing to compare its result against. The call still has to happen, because the expression underneath, x + y inside print x + y;, is exactly where the interesting error lives, and skipping the call would skip checking it.
check_node(node->as.stmt.value, src); result = TYPE_VOID; break; case NODE_EXPR_STMT: check_node(node->as.stmt.value, src); result = TYPE_VOID; break; } node->vtype = result; return result;}
Now step back and look at the guard that opens both cases in step 8:
if (operand == TYPE_ERROR), if (left == TYPE_ERROR || right == TYPE_ERROR).
That single line in each case is the entire propagation policy from step 2 made real.
Take let x = true + 1; print x;. Checking true + 1 reports one
message, "arithmetic needs Int on both sides", and hands back TYPE_ERROR.
record_type stores x as TYPE_ERROR. When
print x; is checked, NODE_IDENT looks x up and gets
TYPE_ERROR back, but nothing in the NODE_PRINT case compares
that to anything, so no second message fires. One real mistake, one message. If a later
chapter added, say, a rule that print refuses Void, that rule
would need the same guard at its own top, checking for TYPE_ERROR before
comparing anything else. The rule is not "the addition happens to check for it". The
rule is "every rule checks for it first, always, by hand, because C has no feature that
does it for you."
Checkpoint
Ten cases, ten NodeType values, clean under -Werror. The
checker is complete for everything the language can currently parse.
let x: Int = 10; parses, and the annotation is checked against what
10 already infers to. It never gets to decide that answer.
src/lexer.h · inside the typedef enum, next to TOK_EQUAL
TOK_EQUAL, TOK_COLON,Punctuation, so it sits with the other single-character tokens rather than near the keywords.
src/lexer.c · token_type_name, next to the TOK_EQUAL case · the switch(c) at the bottom of lexer_next, next to the '=' case
case TOK_COLON: return "COLON"; case '=': return make_token(lx, TOK_EQUAL, start); case ':': return make_token(lx, TOK_COLON, start);No lookahead needed. Nothing else in the language starts with a colon, so a single character is always the whole token, unlike the = versus == question chapter 4 flagged and deliberately left alone.
Next, teach type.c to read a type name off the source text. This belongs
here, not in the parser, because recognizing what counts as a type is the same question
type_name already answers in reverse.
src/type.h · add the declaration · src/type.c · add the function, below type_name
int type_from_name(const char *name, int length, TypeKind *out);int type_from_name(const char *name, int length, TypeKind *out) {Returns 1 and writes through *out on a match, 0 and leaves *out alone otherwise. A plain int return rather than bool, matching the style parser_match already set for a yes/no answer that also has to hand something back.
if (length == 3 && memcmp(name, "Int", 3) == 0) { *out = TYPE_INT; return 1; } if (length == 4 && memcmp(name, "Bool", 4) == 0) { *out = TYPE_BOOL; return 1; } return 0;}
Int and Bool are not keywords. They never went into
lexer.h. The lexer hands them back as an ordinary TOK_IDENT,
the same token kind a variable name gets, and it is only here, where the parser
specifically expects a type, that the text gets asked whether it names one. That keeps
the lexer from needing to know that types exist at all.
src/parser.c · in the function that parses a let or var statement, directly after it consumes the name, before it requires TOK_EQUAL
bool has_annotation = false; TypeKind annotation = TYPE_VOID; if (parser_match(p, TOK_COLON)) {The whole annotation is optional, so the first move is checking whether it is even there. If it is not, both locals above keep their defaults and every downstream line reads that as "no annotation given".
if (!parser_check(p, TOK_IDENT)) { error_at(p, "expected a type name"); return NULL; } const char *type_start = p->current.start; int type_length = p->current.length; parser_advance(p); if (!type_from_name(type_start, type_length, &annotation)) {The identifier is already consumed by the time this check runs, which is why the error below has to rebuild its own offset from type_start rather than reading p->current, which has already moved past it.
diag_error(p->lexer.src, type_start - p->lexer.src, "unknown type '%.*s'", type_length, type_start); return NULL; } has_annotation = true; }src/ast.h · the binding member of the union, and the ast_binding declaration
Chapter 5 gave the binding member three fields and the constructor four parameters:
struct { const char *name; int length; Node *value;Add the two new fields to the end of this member, so the existing three keep their names.
bool has_annotation; TypeKind annotation; } binding;You need #include "type.h" in ast.h for TypeKind, and <stdbool.h> for bool. Watch the include direction: type.h must not include ast.h back, or you get a cycle the include guards turn into a confusing missing-type error rather than an obvious loop.
Node *ast_binding(NodeType type, const char *name, int length, Node *value,Two more parameters on the constructor, and two more lines in its body in ast.c storing them.
bool has_annotation, TypeKind annotation);
There are three call sites in parse_statement: the shared let and var
branch, and the assignment branch. Pass false, TYPE_VOID from the
assignment one, because x = 5; has no place to write a type, and pass the
two variables you just parsed from the let and var branch.
src/type.c · back in check_node's NODE_LET / NODE_VAR case, before record_type
TypeKind value_type = check_node(node->as.binding.value, src); if (node->as.binding.has_annotation && value_type != TYPE_ERROR &&Skips the comparison entirely when value_type is already TYPE_ERROR. Same propagation guard as step 9: the initializer already produced its one message, so the annotation check does not need to pile on a second one about a value that was already wrong for an unrelated reason.
node->as.binding.annotation != value_type) { diag_error(src, node->offset, "'%.*s' is declared %s but initialized with %s", node->as.binding.length, node->as.binding.name, type_name(node->as.binding.annotation), type_name(value_type)); value_type = TYPE_ERROR; } record_type(node->as.binding.name, node->as.binding.length, value_type);
Look at the order in that block. value_type is computed first, entirely by
recursing into 10 or whatever the initializer is, with no knowledge that
an annotation even exists. Only after that answer is final does the annotation get
compared to it. Flip that order, and let the annotation set x's type
directly instead of checking it, and you have quietly built a second, contradictory
source of truth: let x: Bool = 10; would make x a
Bool holding an Int, and every later use of x
would trust a promise the initializer never kept. Checking a written annotation costs
one comparison. Trusting it costs correctness, for the entire rest of the program, in
exchange for looking like it caught the same mistake.
Checkpoint
One call in the driver, then a test file that starts with the example from step 1.
src/main.c · in main(), between where the program finishes parsing and where the first statement gets evaluated
#include "type.h" type_check_program(program, src);Your chapter 5 driver names its parsed result something; substitute that name for program here. If any statement's checking reported an error, diag_error has already printed it to stderr by the time this call returns.
if (had_type_error) {Exactly the same fail-fast shape main() already uses when parser_parse returns NULL: stop, free what was allocated, return a non-zero status, and never reach the evaluator. Whether "did an error happen" comes back as a return value from type_check_program or a flag it sets is your call to make when you write it; the shape of the check afterward does not change either way.
return 1; }
This is the line that turns everything above from a function nobody calls into a real
gate. Before it, check_node was correct and untested by the compiler itself.
After it, no program with a type error ever reaches eval.
tests/test_type_1.c · new file, copy the shape of tests/test_parser_1.c
Assertions to write, each one exercising a rule from a specific step above.
type_name(TYPE_INT) ... TYPE_ERRORAll four strings, in order. Cheapest possible test and it still would have caught a typo in step 3.
"1 + 2"Parse it, run type_check_program, assert the tree's top-level expression statement type checks with no error path taken. The happy case, so a later assertion failing tells you something actually broke instead of nothing having worked in the first place.
"true"One expression, type TYPE_BOOL. Proves step 4 and step 7's leaf case both work without any arithmetic involved.
"let x = 10; let y = true; print x + y;"The one from step 1. This is the assertion the whole chapter is for. Check that the program is refused, not that it silently keeps going.
"let x: Int = 10;" then "let x: Bool = 10;"One accepted, one refused, proving the annotation check runs in both directions.
"let x = true + 1; print x;"Redirect stderr to a buffer and count the lines that start with error:. Exactly one. This is the propagation test from step 9, and it is the one most likely to quietly break if a later change adds a check that forgets the TYPE_ERROR guard.
"print z;"Refused with 'z' is not declared. Proves lookup_type's failure path from step 7, not just its success path.
tests/golden/ · new fixtures, following the existing .iz / .expected pairs
The unit test above checks that an error happens. The golden tests pin down exactly what
it says, the same discipline chapter 3's diagnostics already earned: a compiler error is
a user interface, and changing its wording should show up as a diff someone has to
approve, not a side effect nobody notices. Add at least
tests/golden/type-mismatch.iz for the arithmetic case and
tests/golden/annotation-mismatch.iz for the annotation case, each with a
matching .expected file holding the exact stderr output, in the same
rustc-style layout diag_error already produces, right down to the caret's
column.
Checkpoint
Every earlier golden case still matches, your two new ones pass, and
let x = 10; let y = true; print x + y; is now a program izvor refuses to
run, with a message that says exactly why.